SLOPSHOPPER

canopy

Worktrees, services and Claude's own processes in one pane

panebandguardcommandtoastprompt
v0.1.0MITupdated 2026-10-07igaurab/claude-canopy/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · canopy
│ ┃ Canopy ✕ › fix the failing auth test and add an audit log call │ ┃ [ New worktree ] [ Refresh ] │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /canopy │ ⎿ canopy: Canopy pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Canopy
[ New worktree ] [ Refresh ]
README

claude-canopy

A Claude Code mod (plugin name: canopy) that brings a command center into Claude Code, in the terminal and in the Desktop app's Code tab:

  • Worktrees of the repository: branch, ahead/behind, uncommitted changes, pull request, last commit, and which other Claude sessions work where.
  • Services from a canopy.yaml: started as Claude Code background tasks, each worktree on its own ports, with health checks, restarts and logs.
  • Background commands Claude starts on its own: listed, with their output and a Stop button.
  • Enter a worktree: the session moves there, so Claude Code's own diff view and Files pane show it. A quick diff of any worktree is also in the pane.
  • Create and remove worktrees, with copy and setup steps.

Works in any git repository. It does not need the canopy app or canopyd.

Use

TypeWhat happens
/canopyOpens the pane. Number keys open a worktree; b goes back.
/canopy statusPrints worktrees and services as text.
/canopy start [service…]Starts services of the worktree the session is in.
/canopy stop [service…], /canopy restart [service…]Stops or restarts them.
/canopy logs <service>Shows a service's newest output, live.
/canopy new <branch>Creates a worktree under .claude/worktrees/.
/canopy enter <worktree>Moves the session into it (main to go back).
/canopy remove <worktree>Removes it, after a confirm.
/canopy setupRuns the setup steps in the current worktree.
/canopy trustAllows every command of the current canopy.yaml at once.

The first time a command from canopy.yaml would run in a repository, the mod asks. Services stop when the session ends; Claude Code warns before a quit that would stop them, and the next session offers to restart them.

Claude gets a service tool and a short note about the project's services, so it starts dev servers through canopy instead of raw shell commands.

canopy.yaml

Read from the worktree, else from the main checkout. Without one, Desktop's .claude/launch.json is read instead.

version: 1

ports:
  web: { preferred: 5173 }      # the main checkout gets 5173, other worktrees a port near it
  api: { range: [4000, 4099] }

env:
  API_URL: http://127.0.0.1:${ports.api}

copy: [.env.local]              # copied from the main checkout into a new worktree
setup:
  - npm install                 # run once in a new worktree

services:
  api:
    run: node server.js --port ${ports.api}
    health:
      http: http://127.0.0.1:${ports.api}/healthz
      start_period: 15s
  web:
    run: npm run dev
    cwd: apps/web
    ports: [web]                # a single port is also exported as PORT
    depends_on: [api]           # waits for api's health check
    restart: on-failure         # never | on-failure (default) | always
    autostart: true

Templates: ${ports.x}, ${worktree.path}, ${worktree.name}, ${worktree.branch}, ${env.X}. Services run on the host; runtime: docker, compose and databases are reported and skipped.

Install

From the GitHub marketplace, for every session (terminal and Desktop):

claude plugin marketplace add igaurab/claude-canopy
claude plugin install canopy@canopy

claude plugin update canopy picks up a new version; /reload-plugins in a running session reloads it.

Try it locally

Pick one of these; do not combine them, or the plugin loads twice.

One terminal session, from any git repository (the playground/ folder is a ready-made one):

claude --plugin-dir ~/dev/claude-canopy/mod

Then type /canopy. Edits to mod/ hot-reload while the session runs.

Desktop and every new session: in the env block of ~/.claude/settings.json

"CLAUDE_CODE_PLUGIN_DIRS": "~/dev/claude-canopy/mod",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"

Remove the two lines to unload it.

As an installed plugin, read from this folder: the install is a pointer to the working copy, so edits reach a session with /reload-plugins and no reinstall.

claude plugin marketplace add ~/dev/claude-canopy
claude plugin install canopy@canopy

Run with claude --debug to see hooks the engine skipped or trees it refused.

Develop

npm install        # typescript, esbuild, yaml
npm run vendor     # bundles the yaml parser into mod/vendor/yaml.js
npm run validate   # claude plugin validate mod
npm test           # claude plugin test mod
npm run typecheck

playground/ (git-ignored) is a scratch repository with a canopy.yaml for trying things. docs/engine-notes.md holds what the engine does and does not allow; read it before changing mod/hooks/register.tsx.

Source 6 files
hooks/register.tsx 2040 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  ChangedFile,
6  ClaudeProc,
7  Config,
8  Health,
9  Peer,
10  PullRequest,
11  RepoState,
12  ServiceDef,
13  ServiceRun,
14  ServiceStatus,
15  View,
16  WorktreeInfo,
17} from '../types'
18import { parse } from '../vendor/yaml.js'
19import {
20  CONFIG_FILES,
21  LAUNCH_FILE,
22  STARTER,
23  emptyConfig,
24  hash,
25  portCandidates,
26  readConfig,
27  readLaunchJson,
28  render,
29  startOrder,
30} from './config'
31import type { Scope } from './config'
32import {
33  baseName,
34  isValidWorktreeName,
35  owningWorktree,
36  parseAheadBehind,
37  parseStatus,
38  parseWorktreeList,
39} from './git'
40import type { Worktree } from './git'
41import {
42  ago,
43  clip,
44  dirName,
45  joinPath,
46  parseOutputFile,
47  parseTaskNotifications,
48  serviceCommand,
49  shellQuote,
50} from './procs'
51
52// Everything that touches `$` lives in this one file: the engine follows `$`
53// into functions declared here and never across an import. The files beside
54// it hold pure helpers.
55
56const PANE = 'canopy'
57const TOOL = 'mcp__canopy__service'
58const TICK_MS = 3000
59const MAX_RESTARTS = 3
60const MAX_PATCH = 60_000
61
62const repoAtom = atom({ plugin: 'canopy', key: 'repo' } as const, null)
63const viewAtom = atom({ plugin: 'canopy', key: 'view' } as const, { kind: 'list' })
64const servicesAtom = atom({ plugin: 'canopy', key: 'services' } as const, {})
65const procsAtom = atom({ plugin: 'canopy', key: 'procs' } as const, [])
66const changesAtom = atom({ plugin: 'canopy', key: 'changes' } as const, null)
67const diffAtom = atom({ plugin: 'canopy', key: 'diff' } as const, null)
68const logAtom = atom({ plugin: 'canopy', key: 'log' } as const, null)
69const stoppedAtom = atom({ plugin: 'canopy', key: 'stopped' } as const, null)
70const flashAtom = atom({ plugin: 'canopy', key: 'flash' } as const, null)
71
72// The module's own, gone at a reload: the commands it is raising right now
73// (what its `tool.check` hook lets through), and what it knows of the session.
74const own = new Set<string>()
75let home = ''
76let sessionId = ''
77let presence = { title: '', busy: false }
78let isRefreshing = false
79let isSupervising = false
80let isToolOffered = false
81let ticks = 0
82let pulls: { until: number; byBranch: Record<string, PullRequest> } = {
83  until: 0,
84  byBranch: {},
85}
86
87const ACTIVE: readonly ServiceStatus[] = [
88  'starting',
89  'running',
90  'healthy',
91  'unhealthy',
92]
93
94const isActive = (run: ServiceRun) => ACTIVE.includes(run.status)
95const isUp = (run: ServiceRun) => run.status === 'running' || run.status === 'healthy'
96const serviceKey = (worktree: string, name: string) => `${worktree}::${name}`
97
98const say = (error: unknown) =>
99  error instanceof Error ? error.message : String(error)
100
101const statusColor = (status: ServiceStatus) =>
102  status === 'healthy' || status === 'running'
103    ? 'success'
104    : status === 'starting'
105      ? 'warning'
106      : status === 'unhealthy' || status === 'failed'
107        ? 'error'
108        : 'inactive'
109
110type Ran = { exitCode: number; stdout: string; stderr: string }
111
112// A host command by argv; a command that cannot start reads as exit -1.
113async function run(
114  $: EngineInterface,
115  argv: readonly string[],
116  init: { cwd?: string; timeoutMs?: number } = {},
117): Promise<Ran> {
118  try {
119    return await $.process.run(argv, { timeoutMs: 10_000, ...init })
120  } catch (error) {
121    return { exitCode: -1, stdout: '', stderr: say(error) }
122  }
123}
124
125const git = ($: EngineInterface, cwd: string, args: readonly string[], timeoutMs = 10_000) =>
126  run($, ['git', '-C', cwd, ...args], { timeoutMs })
127
128async function flash($: EngineInterface, text: string) {
129  $.ui.toast(text)
130  await update($, flashAtom, () => text)
131}
132
133async function setView($: EngineInterface, view: View) {
134  await update($, flashAtom, () => null)
135  await update($, viewAtom, () => view)
136}
137
138// ---------------------------------------------------------------- worktrees
139
140async function pullRequests($: EngineInterface, root: string) {
141  const now = await $.clock.now()
142
143  if (now < pulls.until) {
144    return pulls.byBranch
145  }
146
147  const ran = await run(
148    $,
149    ['gh', 'pr', 'list', '--json', 'number,title,state,headRefName', '--limit', '50'],
150    { cwd: root, timeoutMs: 8000 },
151  )
152  const byBranch: Record<string, PullRequest> = {}
153
154  if (ran.exitCode === 0) {
155    try {
156      for (const pr of JSON.parse(ran.stdout) as Array<Record<string, unknown>>) {
157        byBranch[String(pr.headRefName)] = {
158          number: Number(pr.number),
159          title: String(pr.title),
160          state: String(pr.state).toLowerCase(),
161        }
162      }
163    } catch {
164      // not JSON: no pull requests to show
165    }
166  }
167
168  // gh missing or signed out: ask again much later
169  pulls = { until: now + (ran.exitCode === 0 ? 60_000 : 600_000), byBranch }
170
171  return byBranch
172}
173
174async function describe(
175  $: EngineInterface,
176  tree: Worktree,
177  isMain: boolean,
178  current: string,
179  base: string | null,
180  prs: Record<string, PullRequest>,
181): Promise<WorktreeInfo> {
182  const hasBase = !isMain && base !== null && tree.branch !== null
183  const [status, counts, last] = await Promise.all([
184    git($, tree.path, ['status', '--porcelain=v1']),
185    hasBase
186      ? git($, tree.path, ['rev-list', '--left-right', '--count', `${base}...HEAD`])
187      : Promise.resolve({ exitCode: 1, stdout: '', stderr: '' }),
188    git($, tree.path, ['log', '-1', '--format=%s%x1f%cr']),
189  ])
190  const [subject = '', when = ''] = last.exitCode === 0 ? last.stdout.trim().split('\x1f') : []
191  const { ahead, behind } =
192    counts.exitCode === 0 ? parseAheadBehind(counts.stdout) : { ahead: 0, behind: 0 }
193
194  return {
195    path: tree.path,
196    name: isMain ? (tree.branch ?? baseName(tree.path)) : baseName(tree.path),
197    branch: tree.branch,
198    isMain,
199    isCurrent: tree.path === current,
200    dirty: status.exitCode === 0 ? parseStatus(status.stdout).length : 0,
201    ahead,
202    behind,
203    lastSubject: subject,
204    lastWhen: when,
205    pr: tree.branch === null ? null : (prs[tree.branch] ?? null),
206  }
207}
208
209// A worktree's own canopy.yaml, else the main checkout's, else the same for
210// Desktop's `.claude/launch.json`.
211async function loadConfig($: EngineInterface, path: string, mainRoot: string): Promise<Config> {
212  const folders = path === mainRoot ? [path] : [path, mainRoot]
213
214  for (const folder of folders) {
215    for (const name of CONFIG_FILES) {
216      const file = `${folder}/${name}`
217
218      if (await $.fs.exists(file)) {
219        try {
220          return readConfig(parse(await $.fs.read(file)), name)
221        } catch (error) {
222          return { ...emptyConfig(), source: name, errors: [`${name}: ${say(error)}`] }
223        }
224      }
225    }
226  }
227
228  for (const folder of folders) {
229    const file = `${folder}/${LAUNCH_FILE}`
230
231    if (await $.fs.exists(file)) {
232      try {
233        return readLaunchJson(JSON.parse(await $.fs.read(file)))
234      } catch (error) {
235        return { ...emptyConfig(), source: LAUNCH_FILE, errors: [`${LAUNCH_FILE}: ${say(error)}`] }
236      }
237    }
238  }
239
240  return emptyConfig()
241}
242
243const sessionsDir = () => `${home}/.claude/canopy/sessions`
244
245// The other sessions of this repository that run the mod, from their presence
246// files: what each is working on, and what it left running when it ended.
247async function readPeers($: EngineInterface, root: string): Promise<Peer[]> {
248  const dir = sessionsDir()
249
250  if (home === '' || !(await $.fs.exists(dir))) {
251    return []
252  }
253
254  const now = await $.clock.now()
255  const entries = (await $.fs.list(dir))
256    .filter(entry => entry.name.endsWith('.json') && now - entry.mtimeMs < 86_400_000)
257    .sort((a, b) => b.mtimeMs - a.mtimeMs)
258    .slice(0, 20)
259  const peers: Peer[] = []
260
261  for (const entry of entries) {
262    try {
263      const peer = JSON.parse(await $.fs.read(`${dir}/${entry.name}`)) as Peer
264
265      if (peer.root === root && peer.sessionId !== sessionId) {
266        peers.push(peer)
267      }
268    } catch {
269      // half-written or foreign: not a peer
270    }
271  }
272
273  return peers
274}
275
276async function refresh($: EngineInterface): Promise<RepoState | null> {
277  if (isRefreshing) {
278    return read($, repoAtom)
279  }
280
281  isRefreshing = true
282
283  try {
284    const repo = await $.session.repo()
285
286    if (repo === null) {
287      await update($, repoAtom, () => null)
288
289      return null
290    }
291
292    const listed = await git($, repo.root, ['worktree', 'list', '--porcelain'])
293    const trees = parseWorktreeList(listed.stdout).filter(tree => !tree.isBare)
294    const sessionRoot = await $.session.root()
295    const current = owningWorktree(trees.map(tree => tree.path), sessionRoot) ?? repo.root
296    const base = trees[0]?.branch ?? null
297    const prs = await pullRequests($, repo.root)
298    const worktrees = await Promise.all(
299      trees.map((tree, index) => describe($, tree, index === 0, current, base, prs)),
300    )
301    const configs: Record<string, Config> = {}
302
303    for (const tree of trees) {
304      configs[tree.path] = await loadConfig($, tree.path, repo.root)
305    }
306
307    const state: RepoState = {
308      root: repo.root,
309      current,
310      base,
311      worktrees,
312      configs,
313      peers: await readPeers($, repo.root),
314      at: await $.clock.now(),
315    }
316    await update($, repoAtom, () => state)
317
318    return state
319  } finally {
320    isRefreshing = false
321  }
322}
323
324async function writePresence($: EngineInterface, hasEnded = false) {
325  const repo = await read($, repoAtom)
326
327  if (home === '' || sessionId === '' || repo === null) {
328    return
329  }
330
331  const services = Object.values(await read($, servicesAtom))
332    .filter(isActive)
333    .map(({ worktree, name }) => ({ worktree, name }))
334  const peer: Peer = {
335    sessionId,
336    root: repo.root,
337    cwd: repo.current,
338    title: presence.title,
339    busy: presence.busy && !hasEnded,
340    at: await $.clock.now(),
341    ended: hasEnded,
342    services,
343  }
344
345  await $.fs
346    .write(`${sessionsDir()}/${sessionId}.json`, JSON.stringify(peer))
347    .catch(() => undefined)
348}
349
350// ----------------------------------------------------------------- services
351
352async function isPortOpen($: EngineInterface, host: string, port: number) {
353  const ran = await run($, ['nc', '-z', host, String(port)], { timeoutMs: 2000 })
354
355  return ran.exitCode === 0
356}
357
358// One port per name per worktree, kept across sessions so a worktree's
359// address stays put.
360async function allocatePorts($: EngineInterface, worktree: WorktreeInfo, config: Config) {
361  const all = ((await $.store.get('ports')) ?? {}) as Record<string, Record<string, number>>
362  const mine = { ...(all[worktree.path] ?? {}) }
363  const taken = new Set<number>(Object.values(all).flatMap(ports => Object.values(ports)))
364  let hasChanged = false
365
366  for (const [name, def] of Object.entries(config.ports)) {
367    if (mine[name] !== undefined) {
368      continue
369    }
370
371    for (const candidate of portCandidates(def, name, worktree.path, worktree.isMain)) {
372      if (!taken.has(candidate) && !(await isPortOpen($, '127.0.0.1', candidate))) {
373        mine[name] = candidate
374        taken.add(candidate)
375        hasChanged = true
376        break
377      }
378    }
379  }
380
381  if (hasChanged) {
382    await $.store.set('ports', { ...all, [worktree.path]: mine })
383  }
384
385  return mine
386}
387
388// The person's own yes to the commands a repository's canopy.yaml names,
389// asked once per command per repository.
390async function ensureApproved(
391  $: EngineInterface,
392  root: string,
393  commands: readonly string[],
394  what: string,
395): Promise<boolean> {
396  const all = ((await $.store.get('approved')) ?? {}) as Record<string, string[]>
397  const known = all[root] ?? []
398  const missing = commands.filter(command => !known.includes(command))
399
400  if (missing.length === 0) {
401    return true
402  }
403
404  const shown = missing.map(command => `"${clip(command, 90)}"`).join(', ')
405  const answer = await $.ui
406    .ask(`canopy will run ${shown} for ${what}. Allow these commands in this repo?`, [
407      'Allow',
408      'Cancel',
409    ])
410    .catch(() => 'Cancel')
411
412  if (answer !== 'Allow') {
413    return false
414  }
415
416  await $.store.set('approved', { ...all, [root]: [...known, ...missing] })
417
418  return true
419}
420
421function scopeOf(worktree: WorktreeInfo, ports: Record<string, number>, config: Config): Scope {
422  const scope: Scope = {
423    ports,
424    worktree: { path: worktree.path, name: worktree.name, branch: worktree.branch ?? '' },
425    env: {},
426  }
427
428  for (const [key, value] of Object.entries(config.env)) {
429    scope.env[key] = render(value, scope)
430  }
431
432  return scope
433}
434
435// What a service waits on before it runs: each service it depends on, by that
436// service's own health check. The wait is part of the service's command, so a
437// dependent is started by the same press that started what it waits for.
438function waitsFor(def: ServiceDef, config: Config, scope: Scope): string[] {
439  const waits: string[] = []
440
441  for (const name of def.dependsOn) {
442    const health = config.services.find(one => one.name === name)?.health
443
444    if (health === null || health === undefined) {
445      continue
446    }
447
448    if (health.http !== null) {
449      waits.push(`curl -s -o /dev/null --max-time 2 ${shellQuote(render(health.http, scope))}`)
450    } else {
451      const [host = '127.0.0.1', port = ''] = render(health.tcp ?? '', scope).split(':')
452      waits.push(`nc -z ${shellQuote(host)} ${shellQuote(port)}`)
453    }
454  }
455
456  return waits
457}
458
459// Starts one service as a Claude Code background task of this session.
460async function launch(
461  $: EngineInterface,
462  worktree: WorktreeInfo,
463  def: ServiceDef,
464  config: Config,
465): Promise<ServiceRun> {
466  const key = serviceKey(worktree.path, def.name)
467  const ports = await allocatePorts($, worktree, config)
468  const scope = scopeOf(worktree, ports, config)
469  const env = { ...scope.env }
470
471  for (const [name, value] of Object.entries(def.env)) {
472    env[name] = render(value, scope)
473  }
474
475  const only = def.ports.length === 1 ? ports[def.ports[0] ?? ''] : undefined
476
477  if (only !== undefined && env.PORT === undefined) {
478    env.PORT = String(only)
479  }
480
481  const full = { ...scope, env }
482  const statusFile = `${home}/.claude/canopy/run/${sessionId}/${hash(key)}.exit`
483  const command = serviceCommand({
484    cwd: joinPath(worktree.path, render(def.cwd, full)),
485    env,
486    run: render(def.run, full),
487    statusFile,
488    label: `canopy service: ${def.name} (${worktree.name})`,
489    waits: waitsFor(def, config, full),
490    restart: def.restart,
491    maxRestarts: MAX_RESTARTS,
492  })
493  const failed: ServiceRun = {
494    key,
495    worktree: worktree.path,
496    name: def.name,
497    status: 'failed',
498    taskId: null,
499    ports,
500    outputFile: null,
501    statusFile,
502    startedAt: await $.clock.now(),
503    restarts: 0,
504    exitCode: null,
505    detail: '',
506  }
507
508  // an earlier run's exit code must not read as this one's
509  await run($, ['rm', '-f', statusFile, `${statusFile}.restarts`])
510  own.add(command)
511
512  try {
513    const started = await $.tool.call({
514      tool: 'Bash',
515      command,
516      description: `canopy service ${def.name} (${worktree.name})`,
517      run_in_background: true,
518      consent: `The user started the ${def.name} service from canopy`,
519    })
520
521    if (started.deny !== undefined || started.isError === true) {
522      return { ...failed, detail: clip(started.deny ?? started.text ?? 'did not start', 300) }
523    }
524
525    const taskId = started.result.backgroundTaskId
526
527    if (taskId === undefined) {
528      return { ...failed, detail: 'the command ended at once' }
529    }
530
531    return {
532      ...failed,
533      status: def.health === null && def.dependsOn.length === 0 ? 'running' : 'starting',
534      taskId,
535      outputFile: parseOutputFile(started.text ?? ''),
536    }
537  } catch (error) {
538    return { ...failed, detail: say(error) }
539  } finally {
540    own.delete(command)
541  }
542}
543
544async function probe($: EngineInterface, health: Health, scope: Scope) {
545  if (health.http !== null) {
546    return $.http
547      .fetch(render(health.http, scope))
548      .then(response => response.status < 500)
549      .catch(() => false)
550  }
551
552  const [host = '127.0.0.1', port = ''] = render(health.tcp ?? '', scope).split(':')
553
554  return isPortOpen($, host, Number(port))
555}
556
557// One service, one look: has it ended, has it restarted, is what it waits
558// for up, is it healthy. Reads only: a timer runs it, and nothing here starts
559// a process.
560async function step(
561  $: EngineInterface,
562  repo: RepoState,
563  runs: Record<string, ServiceRun>,
564  current: ServiceRun,
565  now: number,
566): Promise<ServiceRun> {
567  const worktree = repo.worktrees.find(one => one.path === current.worktree)
568  const config = repo.configs[current.worktree]
569  const def = config?.services.find(one => one.name === current.name)
570
571  if (!isActive(current) || worktree === undefined || config === undefined || def === undefined) {
572    return current
573  }
574
575  if (await $.fs.exists(current.statusFile)) {
576    const code = Number((await $.fs.read(current.statusFile)).trim())
577
578    return {
579      ...current,
580      status: code === 0 ? 'exited' : 'failed',
581      exitCode: code,
582      detail: `exited ${code}`,
583    }
584  }
585
586  const counter = `${current.statusFile}.restarts`
587  const restarts = (await $.fs.exists(counter))
588    ? Number((await $.fs.read(counter)).trim()) || 0
589    : 0
590  const seen = restarts === current.restarts ? current : { ...current, restarts }
591
592  if (seen.status === 'starting') {
593    const pending = def.dependsOn.filter(name => {
594      const dep = runs[serviceKey(seen.worktree, name)]
595
596      return dep === undefined || !isUp(dep)
597    })
598
599    if (pending.length > 0) {
600      const detail = `waiting for ${pending.join(', ')}`
601
602      return seen.detail === detail ? seen : { ...seen, detail }
603    }
604  }
605
606  if (def.health === null) {
607    return seen.status === 'running' ? seen : { ...seen, status: 'running', detail: '' }
608  }
609
610  if (await probe($, def.health, scopeOf(worktree, seen.ports, config))) {
611    return seen.status === 'healthy' ? seen : { ...seen, status: 'healthy', detail: '' }
612  }
613
614  const grace = def.health.startMs + def.health.retries * def.health.intervalMs
615
616  if (seen.status === 'starting' && now - seen.startedAt < grace) {
617    return seen.detail === '' ? seen : { ...seen, detail: '' }
618  }
619
620  return seen.status === 'unhealthy'
621    ? seen
622    : { ...seen, status: 'unhealthy', detail: 'health check is failing' }
623}
624
625async function setService($: EngineInterface, next: ServiceRun) {
626  await update($, servicesAtom, runs => ({ ...runs, [next.key]: next }))
627}
628
629async function supervise($: EngineInterface) {
630  if (isSupervising) {
631    return
632  }
633
634  isSupervising = true
635
636  try {
637    const repo = await read($, repoAtom)
638
639    if (repo === null) {
640      return
641    }
642
643    let runs = await read($, servicesAtom)
644    let hasChanged = false
645
646    for (const key of Object.keys(runs)) {
647      const before = runs[key]
648
649      if (before === undefined) {
650        continue
651      }
652
653      const after = await step($, repo, runs, before, await $.clock.now())
654
655      if (after !== before) {
656        runs = { ...runs, [key]: after }
657        hasChanged = true
658        await setService($, after)
659
660        const isWorse = ['failed', 'exited', 'unhealthy'].includes(after.status)
661
662        if (after.restarts > before.restarts) {
663          $.ui.toast(`${after.name} restarted (${after.restarts} of ${MAX_RESTARTS})`)
664        } else if (after.status !== before.status && isWorse) {
665          $.ui.toast(`${after.name} ${after.detail || after.status}`)
666        }
667      }
668    }
669
670    if (hasChanged) {
671      await writePresence($)
672    }
673  } finally {
674    isSupervising = false
675  }
676}
677
678// Starts the wanted services and what they depend on, dependencies first.
679// Every start is raised here, from the person's own action.
680async function startServices(
681  $: EngineInterface,
682  path: string,
683  names: readonly string[] | null,
684): Promise<string> {
685  const repo = (await read($, repoAtom)) ?? (await refresh($))
686  const worktree = repo?.worktrees.find(one => one.path === path)
687  const config = repo?.configs[path]
688
689  if (repo === null || worktree === undefined || config === undefined) {
690    return 'no such worktree'
691  }
692
693  const wanted = names ?? config.services.filter(one => one.autostart).map(one => one.name)
694  const unknown = wanted.filter(name => !config.services.some(one => one.name === name))
695
696  if (unknown.length > 0) {
697    return `no service named ${unknown.join(', ')}`
698  }
699
700  if (wanted.length === 0) {
701    return 'no services to start'
702  }
703
704  const { order, cycle } = startOrder(config.services, wanted)
705
706  if (cycle !== null) {
707    return `depends_on has a cycle at ${cycle}`
708  }
709
710  const defs = order
711    .map(name => config.services.find(one => one.name === name))
712    .filter((one): one is ServiceDef => one !== undefined)
713  const isAllowed = await ensureApproved(
714    $,
715    repo.root,
716    defs.map(one => one.run),
717    `${order.join(', ')} in ${worktree.name}`,
718  )
719
720  if (!isAllowed) {
721    return 'not started: the commands were not allowed'
722  }
723
724  const runs = await read($, servicesAtom)
725  const started: string[] = []
726
727  for (const def of defs) {
728    const existing = runs[serviceKey(path, def.name)]
729
730    if (existing !== undefined && isActive(existing)) {
731      continue
732    }
733
734    const next = await launch($, worktree, def, config)
735    await setService($, next)
736    started.push(next.status === 'failed' ? `${def.name} (failed: ${clip(next.detail, 120)})` : def.name)
737  }
738
739  await writePresence($)
740
741  return started.length === 0 ? 'already running' : `starting ${started.join(', ')}`
742}
743
744async function stopTask($: EngineInterface, taskId: string) {
745  return $.tool
746    .call({ tool: 'TaskStop', task_id: taskId, consent: 'The user stopped it from canopy' })
747    .catch(() => undefined)
748}
749
750async function stopServices(
751  $: EngineInterface,
752  path: string,
753  names: readonly string[] | null,
754): Promise<string> {
755  const runs = Object.values(await read($, servicesAtom)).filter(
756    one => one.worktree === path && isActive(one) && (names === null || names.includes(one.name)),
757  )
758
759  for (const one of runs) {
760    if (one.taskId !== null) {
761      await stopTask($, one.taskId)
762    }
763
764    await setService($, { ...one, status: 'stopped', detail: '' })
765  }
766
767  await writePresence($)
768
769  return runs.length === 0 ? 'nothing to stop' : `stopped ${runs.map(one => one.name).join(', ')}`
770}
771
772async function tail($: EngineInterface, file: string, lines: number) {
773  const ran = await run($, ['tail', '-n', String(lines), file])
774
775  return ran.exitCode === 0 ? ran.stdout : `(no output yet: ${ran.stderr.trim()})`
776}
777
778async function openLogs($: EngineInterface, path: string, title: string, file: string | null) {
779  if (file === null) {
780    await flash($, `${title} has no output file yet`)
781
782    return
783  }
784
785  await update($, logAtom, () => null)
786  await setView($, { kind: 'logs', path, title, file })
787  const text = await tail($, file, 200)
788  await update($, logAtom, () => ({ file, text }))
789}
790
791// -------------------------------------------------- Claude's own processes
792
793async function addProc($: EngineInterface, proc: ClaudeProc) {
794  await update($, procsAtom, list =>
795    [...list.filter(one => one.taskId !== proc.taskId), proc].slice(-50),
796  )
797}
798
799async function endProcs($: EngineInterface, ends: ReadonlyMap<string, string>) {
800  if (ends.size === 0) {
801    return
802  }
803
804  await update($, procsAtom, list =>
805    list.map(one => {
806      const status = ends.get(one.taskId)
807
808      if (status === undefined || one.status !== 'running') {
809        return one
810      }
811
812      const isKnown = ['completed', 'failed', 'killed'].includes(status)
813
814      return { ...one, status: isKnown ? (status as ClaudeProc['status']) : 'ended' }
815    }),
816  )
817}
818
819// ---------------------------------------------------------- worktree actions
820
821async function enterWorktree($: EngineInterface, worktree: WorktreeInfo) {
822  const consent = `The user pressed Enter on the ${worktree.name} worktree in canopy`
823  const leave = () => $.tool.call({ tool: 'ExitWorktree', action: 'keep', consent })
824
825  if (worktree.isMain) {
826    const left = await leave()
827    const hasLeft = left.deny === undefined && left.isError !== true
828
829    await flash(
830      $,
831      hasLeft
832        ? 'back in the main checkout'
833        : clip(left.deny ?? left.text ?? 'could not leave', 300),
834    )
835  } else {
836    const enter = () => $.tool.call({ tool: 'EnterWorktree', path: worktree.path, consent })
837    let entered = await enter()
838
839    // already inside another worktree: leave it, then enter this one
840    if (entered.deny === undefined && entered.isError === true) {
841      await leave()
842      entered = await enter()
843    }
844
845    const isIn = entered.deny === undefined && entered.isError !== true
846
847    await flash(
848      $,
849      isIn
850        ? `session is in ${worktree.name}; Claude Code's diff view and Files show it now`
851        : clip(entered.deny ?? entered.text ?? 'could not enter', 300),
852    )
853  }
854
855  await refresh($)
856}
857
858async function runSetup($: EngineInterface, path: string): Promise<string> {
859  const repo = await read($, repoAtom)
860  const config = repo?.configs[path]
861  const worktree = repo?.worktrees.find(one => one.path === path)
862
863  if (repo === null || config === undefined || worktree === undefined) {
864    return 'no such worktree'
865  }
866
867  if (config.setup.length === 0) {
868    return 'no setup steps'
869  }
870
871  const isAllowed = await ensureApproved(
872    $,
873    repo.root,
874    config.setup.map(one => one.run),
875    `setup of ${worktree.name}`,
876  )
877
878  if (!isAllowed) {
879    return 'setup not run: the commands were not allowed'
880  }
881
882  const command = config.setup
883    .map(one => `( cd ${shellQuote(joinPath(path, one.cwd))} && ${one.run} )`)
884    .join(' && ')
885
886  own.add(command)
887
888  try {
889    const started = await $.tool.call({
890      tool: 'Bash',
891      command,
892      description: `canopy setup (${worktree.name})`,
893      run_in_background: true,
894      consent: `The user ran setup for ${worktree.name} from canopy`,
895    })
896
897    if (started.deny !== undefined || started.isError === true) {
898      return clip(started.deny ?? started.text ?? 'setup did not start', 300)
899    }
900
901    const taskId = started.result.backgroundTaskId
902
903    if (taskId !== undefined) {
904      await addProc($, {
905        taskId,
906        kind: 'setup',
907        command: clip(config.setup.map(one => one.run).join(' && '), 200),
908        root: path,
909        startedAt: await $.clock.now(),
910        status: 'running',
911        outputFile: parseOutputFile(started.text ?? ''),
912      })
913    }
914
915    return 'setup started'
916  } finally {
917    own.delete(command)
918  }
919}
920
921async function createWorktree($: EngineInterface, name: string) {
922  const repo = await refresh($)
923
924  if (repo === null) {
925    return
926  }
927
928  if (!isValidWorktreeName(name)) {
929    await flash($, `"${name}" is not a usable branch name`)
930
931    return
932  }
933
934  const path = `${repo.root}/.claude/worktrees/${name.replace(/\//g, '-')}`
935  const made = await git($, repo.root, ['worktree', 'add', '-b', name, path], 60_000)
936
937  if (made.exitCode !== 0) {
938    await flash($, `${clip(made.stderr.trim(), 300)}`)
939
940    return
941  }
942
943  const config = await loadConfig($, path, repo.root)
944
945  for (const file of config.copy) {
946    const from = joinPath(repo.root, file)
947    const to = joinPath(path, file)
948    const isInside = !file.startsWith('/') && !file.split('/').includes('..')
949
950    if (isInside && (await $.fs.exists(from)) && !(await $.fs.exists(to))) {
951      await run($, ['mkdir', '-p', dirName(to)])
952      await run($, ['cp', '-R', from, to])
953    }
954  }
955
956  await refresh($)
957  await setView($, { kind: 'worktree', path, tab: 'services' })
958  await flash(
959    $,
960    config.setup.length > 0
961      ? `created ${name}; ${await runSetup($, path)}`
962      : `created ${name}`,
963  )
964}
965
966async function destroyWorktree($: EngineInterface, worktree: WorktreeInfo) {
967  const repo = await read($, repoAtom)
968
969  if (repo === null || worktree.isMain) {
970    await flash($, 'the main checkout is not removed from here')
971
972    return
973  }
974
975  if (worktree.isCurrent) {
976    await flash($, 'this session is in that worktree; leave it first')
977
978    return
979  }
980
981  const status = await git($, worktree.path, ['status', '--porcelain=v1'])
982  const dirty = parseStatus(status.stdout).length
983  const remove = dirty > 0 ? 'Discard and remove' : 'Remove'
984  const question =
985    dirty > 0
986      ? `Remove the worktree ${worktree.name} and discard its ${dirty} uncommitted ${dirty === 1 ? 'change' : 'changes'}?`
987      : `Remove the worktree ${worktree.name}?`
988  const answer = await $.ui.ask(question, [remove, 'Cancel']).catch(() => 'Cancel')
989
990  if (answer !== remove) {
991    return
992  }
993
994  await stopServices($, worktree.path, null)
995
996  const args = ['worktree', 'remove', ...(dirty > 0 ? ['--force'] : []), worktree.path]
997  const removed = await git($, repo.root, args, 60_000)
998
999  if (removed.exitCode !== 0) {
1000    await flash($, `${clip(removed.stderr.trim(), 300)}`)
1001
1002    return
1003  }
1004
1005  const ports = ((await $.store.get('ports')) ?? {}) as Record<string, Record<string, number>>
1006  const { [worktree.path]: freed, ...kept } = ports
1007
1008  if (freed !== undefined) {
1009    await $.store.set('ports', kept)
1010  }
1011
1012  // a safe delete: git keeps a branch that is not merged
1013  const deleted =
1014    worktree.branch === null ? null : await git($, repo.root, ['branch', '-d', worktree.branch])
1015  const isBranchKept = deleted !== null && deleted.exitCode !== 0
1016
1017  await refresh($)
1018  await setView($, { kind: 'list' })
1019  await flash(
1020    $,
1021    isBranchKept
1022      ? `removed ${worktree.name}; kept the branch ${worktree.branch} (not merged)`
1023      : `removed ${worktree.name}`,
1024  )
1025}
1026
1027async function openChanges($: EngineInterface, path: string) {
1028  await setView($, { kind: 'worktree', path, tab: 'changes' })
1029  const status = await git($, path, ['status', '--porcelain=v1', '-uall'])
1030  const files = parseStatus(status.stdout).slice(0, 200)
1031  await update($, changesAtom, () => ({ path, files }))
1032}
1033
1034async function openDiff($: EngineInterface, path: string, file: ChangedFile) {
1035  await update($, diffAtom, () => null)
1036  await setView($, { kind: 'diff', path, file: file.path })
1037
1038  const isNew = file.code === '??'
1039  const ran = isNew
1040    ? await git($, path, ['diff', '--no-index', '--', '/dev/null', file.path])
1041    : await git($, path, ['diff', 'HEAD', '--', file.path])
1042  const patch =
1043    ran.stdout === '' && !isNew
1044      ? (await git($, path, ['diff', '--cached', '--', file.path])).stdout
1045      : ran.stdout
1046
1047  await update($, diffAtom, () => ({
1048    file: file.path,
1049    patch: patch.length > MAX_PATCH ? patch.slice(0, patch.lastIndexOf('\n', MAX_PATCH)) : patch,
1050  }))
1051}
1052
1053async function writeStarter($: EngineInterface, path: string) {
1054  const file = `${path}/canopy.yaml`
1055
1056  if (await $.fs.exists(file)) {
1057    await flash($, 'canopy.yaml is already there')
1058
1059    return
1060  }
1061
1062  await $.fs.write(file, STARTER)
1063  await refresh($)
1064  await flash($, `wrote ${file}; edit it, then press Refresh`)
1065}
1066
1067// What the last session of this repository left running when it ended.
1068async function findStopped($: EngineInterface, repo: RepoState) {
1069  const left = repo.peers
1070    .filter(peer => peer.ended && peer.services.length > 0)
1071    .sort((a, b) => b.at - a.at)[0]
1072
1073  if (left === undefined) {
1074    return
1075  }
1076
1077  // a session moved to the background keeps its services running
1078  const ports = ((await $.store.get('ports')) ?? {}) as Record<string, Record<string, number>>
1079  const services: typeof left.services = []
1080
1081  for (const one of left.services) {
1082    const def = repo.configs[one.worktree]?.services.find(d => d.name === one.name)
1083    const port = ports[one.worktree]?.[def?.ports[0] ?? '']
1084
1085    if (def !== undefined && (port === undefined || !(await isPortOpen($, '127.0.0.1', port)))) {
1086      services.push(one)
1087    }
1088  }
1089
1090  await update($, stoppedAtom, () =>
1091    services.length === 0 ? null : { sessionId: left.sessionId, services },
1092  )
1093}
1094
1095async function settleStopped($: EngineInterface, restarts: boolean) {
1096  const note = await read($, stoppedAtom)
1097
1098  if (note === null) {
1099    return
1100  }
1101
1102  await update($, stoppedAtom, () => null)
1103
1104  // the note is answered: its file has no more to say
1105  await run($, ['rm', '-f', `${sessionsDir()}/${note.sessionId}.json`])
1106
1107  if (restarts) {
1108    const byWorktree = new Map<string, string[]>()
1109
1110    for (const one of note.services) {
1111      byWorktree.set(one.worktree, [...(byWorktree.get(one.worktree) ?? []), one.name])
1112    }
1113
1114    for (const [path, names] of byWorktree) {
1115      await flash($, `${await startServices($, path, names)}`)
1116    }
1117  }
1118}
1119
1120async function isPaneShown($: EngineInterface) {
1121  return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
1122}
1123
1124async function tick($: EngineInterface) {
1125  ticks += 1
1126
1127  if (Object.values(await read($, servicesAtom)).some(isActive)) {
1128    await supervise($)
1129  }
1130
1131  if (!(await isPaneShown($))) {
1132    return
1133  }
1134
1135  const view = await read($, viewAtom)
1136
1137  if (view.kind === 'logs') {
1138    const text = await tail($, view.file, 200)
1139    await update($, logAtom, () => ({ file: view.file, text }))
1140  } else if (ticks % 3 === 0) {
1141    await refresh($)
1142  }
1143}
1144
1145function statusText(
1146  repo: RepoState,
1147  runs: Record<string, ServiceRun>,
1148  procs: readonly ClaudeProc[],
1149) {
1150  const lines: string[] = []
1151
1152  for (const worktree of repo.worktrees) {
1153    const config = repo.configs[worktree.path]
1154    const counts = [
1155      worktree.ahead > 0 ? `↑${worktree.ahead}` : '',
1156      worktree.behind > 0 ? `↓${worktree.behind}` : '',
1157      worktree.dirty > 0 ? `~${worktree.dirty}` : 'clean',
1158    ]
1159      .filter(Boolean)
1160      .join(' ')
1161
1162    lines.push(
1163      `${worktree.isCurrent ? '▸' : ' '} ${worktree.name} [${worktree.branch ?? 'detached'}] ${counts}` +
1164        (worktree.pr === null ? '' : ` #${worktree.pr.number} ${worktree.pr.state}`),
1165    )
1166
1167    for (const def of config?.services ?? []) {
1168      const one = runs[serviceKey(worktree.path, def.name)]
1169      const ports = def.ports
1170        .map(name => (one?.ports[name] === undefined ? name : `${name}:${one.ports[name]}`))
1171        .join(' ')
1172
1173      lines.push(
1174        `    ${def.name}: ${one?.status ?? 'not started'} ${ports} ${one?.detail ?? ''}`.trimEnd(),
1175      )
1176    }
1177
1178    for (const error of config?.errors ?? []) {
1179      lines.push(`    ! ${error}`)
1180    }
1181  }
1182
1183  for (const proc of procs.filter(one => one.status === 'running')) {
1184    lines.push(`  background ${proc.taskId}: ${proc.command}`)
1185  }
1186
1187  return lines.join('\n')
1188}
1189
1190// The model's tool for the services, listed only in a project that declares
1191// some: a session elsewhere keeps its tool list as it was.
1192async function offerTool($: EngineInterface, repo: RepoState) {
1193  if (isToolOffered || (repo.configs[repo.current]?.services.length ?? 0) === 0) {
1194    return
1195  }
1196
1197  isToolOffered = true
1198  await $.tool.register({
1199    name: 'service',
1200    description:
vendor/yaml.js 6643 lines
1var __defProp = Object.defineProperty;
2var __export = (target, all) => {
3  for (var name in all)
4    __defProp(target, name, { get: all[name], enumerable: true });
5};
6
7// node_modules/yaml/browser/dist/index.js
8var dist_exports = {};
9__export(dist_exports, {
10  Alias: () => Alias,
11  CST: () => cst_exports,
12  Composer: () => Composer,
13  Document: () => Document,
14  Lexer: () => Lexer,
15  LineCounter: () => LineCounter,
16  Pair: () => Pair,
17  Parser: () => Parser,
18  Scalar: () => Scalar,
19  Schema: () => Schema,
20  YAMLError: () => YAMLError,
21  YAMLMap: () => YAMLMap,
22  YAMLParseError: () => YAMLParseError,
23  YAMLSeq: () => YAMLSeq,
24  YAMLWarning: () => YAMLWarning,
25  isAlias: () => isAlias,
26  isCollection: () => isCollection,
27  isDocument: () => isDocument,
28  isMap: () => isMap,
29  isNode: () => isNode,
30  isPair: () => isPair,
31  isScalar: () => isScalar,
32  isSeq: () => isSeq,
33  parse: () => parse,
34  parseAllDocuments: () => parseAllDocuments,
35  parseDocument: () => parseDocument,
36  stringify: () => stringify3,
37  visit: () => visit,
38  visitAsync: () => visitAsync
39});
40
41// node_modules/yaml/browser/dist/nodes/identity.js
42var ALIAS = Symbol.for("yaml.alias");
43var DOC = Symbol.for("yaml.document");
44var MAP = Symbol.for("yaml.map");
45var PAIR = Symbol.for("yaml.pair");
46var SCALAR = Symbol.for("yaml.scalar");
47var SEQ = Symbol.for("yaml.seq");
48var NODE_TYPE = Symbol.for("yaml.node.type");
49var isAlias = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === ALIAS;
50var isDocument = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === DOC;
51var isMap = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === MAP;
52var isPair = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === PAIR;
53var isScalar = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SCALAR;
54var isSeq = (node) => !!node && typeof node === "object" && node[NODE_TYPE] === SEQ;
55function isCollection(node) {
56  if (node && typeof node === "object")
57    switch (node[NODE_TYPE]) {
58      case MAP:
59      case SEQ:
60        return true;
61    }
62  return false;
63}
64function isNode(node) {
65  if (node && typeof node === "object")
66    switch (node[NODE_TYPE]) {
67      case ALIAS:
68      case MAP:
69      case SCALAR:
70      case SEQ:
71        return true;
72    }
73  return false;
74}
75var hasAnchor = (node) => (isScalar(node) || isCollection(node)) && !!node.anchor;
76
77// node_modules/yaml/browser/dist/visit.js
78var BREAK = Symbol("break visit");
79var SKIP = Symbol("skip children");
80var REMOVE = Symbol("remove node");
81function visit(node, visitor) {
82  const visitor_ = initVisitor(visitor);
83  if (isDocument(node)) {
84    const cd = visit_(null, node.contents, visitor_, Object.freeze([node]));
85    if (cd === REMOVE)
86      node.contents = null;
87  } else
88    visit_(null, node, visitor_, Object.freeze([]));
89}
90visit.BREAK = BREAK;
91visit.SKIP = SKIP;
92visit.REMOVE = REMOVE;
93function visit_(key, node, visitor, path) {
94  const ctrl = callVisitor(key, node, visitor, path);
95  if (isNode(ctrl) || isPair(ctrl)) {
96    replaceNode(key, path, ctrl);
97    return visit_(key, ctrl, visitor, path);
98  }
99  if (typeof ctrl !== "symbol") {
100    if (isCollection(node)) {
101      path = Object.freeze(path.concat(node));
102      for (let i = 0; i < node.items.length; ++i) {
103        const ci = visit_(i, node.items[i], visitor, path);
104        if (typeof ci === "number")
105          i = ci - 1;
106        else if (ci === BREAK)
107          return BREAK;
108        else if (ci === REMOVE) {
109          node.items.splice(i, 1);
110          i -= 1;
111        }
112      }
113    } else if (isPair(node)) {
114      path = Object.freeze(path.concat(node));
115      const ck = visit_("key", node.key, visitor, path);
116      if (ck === BREAK)
117        return BREAK;
118      else if (ck === REMOVE)
119        node.key = null;
120      const cv = visit_("value", node.value, visitor, path);
121      if (cv === BREAK)
122        return BREAK;
123      else if (cv === REMOVE)
124        node.value = null;
125    }
126  }
127  return ctrl;
128}
129async function visitAsync(node, visitor) {
130  const visitor_ = initVisitor(visitor);
131  if (isDocument(node)) {
132    const cd = await visitAsync_(null, node.contents, visitor_, Object.freeze([node]));
133    if (cd === REMOVE)
134      node.contents = null;
135  } else
136    await visitAsync_(null, node, visitor_, Object.freeze([]));
137}
138visitAsync.BREAK = BREAK;
139visitAsync.SKIP = SKIP;
140visitAsync.REMOVE = REMOVE;
141async function visitAsync_(key, node, visitor, path) {
142  const ctrl = await callVisitor(key, node, visitor, path);
143  if (isNode(ctrl) || isPair(ctrl)) {
144    replaceNode(key, path, ctrl);
145    return visitAsync_(key, ctrl, visitor, path);
146  }
147  if (typeof ctrl !== "symbol") {
148    if (isCollection(node)) {
149      path = Object.freeze(path.concat(node));
150      for (let i = 0; i < node.items.length; ++i) {
151        const ci = await visitAsync_(i, node.items[i], visitor, path);
152        if (typeof ci === "number")
153          i = ci - 1;
154        else if (ci === BREAK)
155          return BREAK;
156        else if (ci === REMOVE) {
157          node.items.splice(i, 1);
158          i -= 1;
159        }
160      }
161    } else if (isPair(node)) {
162      path = Object.freeze(path.concat(node));
163      const ck = await visitAsync_("key", node.key, visitor, path);
164      if (ck === BREAK)
165        return BREAK;
166      else if (ck === REMOVE)
167        node.key = null;
168      const cv = await visitAsync_("value", node.value, visitor, path);
169      if (cv === BREAK)
170        return BREAK;
171      else if (cv === REMOVE)
172        node.value = null;
173    }
174  }
175  return ctrl;
176}
177function initVisitor(visitor) {
178  if (typeof visitor === "object" && (visitor.Collection || visitor.Node || visitor.Value)) {
179    return Object.assign({
180      Alias: visitor.Node,
181      Map: visitor.Node,
182      Scalar: visitor.Node,
183      Seq: visitor.Node
184    }, visitor.Value && {
185      Map: visitor.Value,
186      Scalar: visitor.Value,
187      Seq: visitor.Value
188    }, visitor.Collection && {
189      Map: visitor.Collection,
190      Seq: visitor.Collection
191    }, visitor);
192  }
193  return visitor;
194}
195function callVisitor(key, node, visitor, path) {
196  if (typeof visitor === "function")
197    return visitor(key, node, path);
198  if (isMap(node))
199    return visitor.Map?.(key, node, path);
200  if (isSeq(node))
201    return visitor.Seq?.(key, node, path);
202  if (isPair(node))
203    return visitor.Pair?.(key, node, path);
204  if (isScalar(node))
205    return visitor.Scalar?.(key, node, path);
206  if (isAlias(node))
207    return visitor.Alias?.(key, node, path);
208  return void 0;
209}
210function replaceNode(key, path, node) {
211  const parent = path[path.length - 1];
212  if (isCollection(parent)) {
213    parent.items[key] = node;
214  } else if (isPair(parent)) {
215    if (key === "key")
216      parent.key = node;
217    else
218      parent.value = node;
219  } else if (isDocument(parent)) {
220    parent.contents = node;
221  } else {
222    const pt = isAlias(parent) ? "alias" : "scalar";
223    throw new Error(`Cannot replace node with ${pt} parent`);
224  }
225}
226
227// node_modules/yaml/browser/dist/doc/directives.js
228var escapeChars = {
229  "!": "%21",
230  ",": "%2C",
231  "[": "%5B",
232  "]": "%5D",
233  "{": "%7B",
234  "}": "%7D"
235};
236var escapeTagName = (tn) => tn.replace(/[!,[\]{}]/g, (ch) => escapeChars[ch]);
237var Directives = class _Directives {
238  constructor(yaml, tags) {
239    this.docStart = null;
240    this.docEnd = false;
241    this.yaml = Object.assign({}, _Directives.defaultYaml, yaml);
242    this.tags = Object.assign({}, _Directives.defaultTags, tags);
243  }
244  clone() {
245    const copy = new _Directives(this.yaml, this.tags);
246    copy.docStart = this.docStart;
247    return copy;
248  }
249  /**
250   * During parsing, get a Directives instance for the current document and
251   * update the stream state according to the current version's spec.
252   */
253  atDocument() {
254    const res = new _Directives(this.yaml, this.tags);
255    switch (this.yaml.version) {
256      case "1.1":
257        this.atNextDocument = true;
258        break;
259      case "1.2":
260        this.atNextDocument = false;
261        this.yaml = {
262          explicit: _Directives.defaultYaml.explicit,
263          version: "1.2"
264        };
265        this.tags = Object.assign({}, _Directives.defaultTags);
266        break;
267    }
268    return res;
269  }
270  /**
271   * @param onError - May be called even if the action was successful
272   * @returns `true` on success
273   */
274  add(line, onError) {
275    if (this.atNextDocument) {
276      this.yaml = { explicit: _Directives.defaultYaml.explicit, version: "1.1" };
277      this.tags = Object.assign({}, _Directives.defaultTags);
278      this.atNextDocument = false;
279    }
280    const parts = line.trim().split(/[ \t]+/);
281    const name = parts.shift();
282    switch (name) {
283      case "%TAG": {
284        if (parts.length !== 2) {
285          onError(0, "%TAG directive should contain exactly two parts");
286          if (parts.length < 2)
287            return false;
288        }
289        const [handle, prefix] = parts;
290        this.tags[handle] = prefix;
291        return true;
292      }
293      case "%YAML": {
294        this.yaml.explicit = true;
295        if (parts.length !== 1) {
296          onError(0, "%YAML directive should contain exactly one part");
297          return false;
298        }
299        const [version] = parts;
300        if (version === "1.1" || version === "1.2") {
301          this.yaml.version = version;
302          return true;
303        } else {
304          const isValid = /^\d+\.\d+$/.test(version);
305          onError(6, `Unsupported YAML version ${version}`, isValid);
306          return false;
307        }
308      }
309      default:
310        onError(0, `Unknown directive ${name}`, true);
311        return false;
312    }
313  }
314  /**
315   * Resolves a tag, matching handles to those defined in %TAG directives.
316   *
317   * @returns Resolved tag, which may also be the non-specific tag `'!'` or a
318   *   `'!local'` tag, or `null` if unresolvable.
319   */
320  tagName(source, onError) {
321    if (source === "!")
322      return "!";
323    if (source[0] !== "!") {
324      onError(`Not a valid tag: ${source}`);
325      return null;
326    }
327    if (source[1] === "<") {
328      const verbatim = source.slice(2, -1);
329      if (verbatim === "!" || verbatim === "!!") {
330        onError(`Verbatim tags aren't resolved, so ${source} is invalid.`);
331        return null;
332      }
333      if (source[source.length - 1] !== ">")
334        onError("Verbatim tags must end with a >");
335      return verbatim;
336    }
337    const [, handle, suffix] = source.match(/^(.*!)([^!]*)$/s);
338    if (!suffix)
339      onError(`The ${source} tag has no suffix`);
340    const prefix = this.tags[handle];
341    if (prefix) {
342      try {
343        return prefix + decodeURIComponent(suffix);
344      } catch (error) {
345        onError(String(error));
346        return null;
347      }
348    }
349    if (handle === "!")
350      return source;
351    onError(`Could not resolve tag: ${source}`);
352    return null;
353  }
354  /**
355   * Given a fully resolved tag, returns its printable string form,
356   * taking into account current tag prefixes and defaults.
357   */
358  tagString(tag) {
359    for (const [handle, prefix] of Object.entries(this.tags)) {
360      if (tag.startsWith(prefix))
361        return handle + escapeTagName(tag.substring(prefix.length));
362    }
363    return tag[0] === "!" ? tag : `!<${tag}>`;
364  }
365  toString(doc) {
366    const lines = this.yaml.explicit ? [`%YAML ${this.yaml.version || "1.2"}`] : [];
367    const tagEntries = Object.entries(this.tags);
368    let tagNames;
369    if (doc && tagEntries.length > 0 && isNode(doc.contents)) {
370      const tags = {};
371      visit(doc.contents, (_key, node) => {
372        if (isNode(node) && node.tag)
373          tags[node.tag] = true;
374      });
375      tagNames = Object.keys(tags);
376    } else
377      tagNames = [];
378    for (const [handle, prefix] of tagEntries) {
379      if (handle === "!!" && prefix === "tag:yaml.org,2002:")
380        continue;
381      if (!doc || tagNames.some((tn) => tn.startsWith(prefix)))
382        lines.push(`%TAG ${handle} ${prefix}`);
383    }
384    return lines.join("\n");
385  }
386};
387Directives.defaultYaml = { explicit: false, version: "1.2" };
388Directives.defaultTags = { "!!": "tag:yaml.org,2002:" };
389
390// node_modules/yaml/browser/dist/doc/anchors.js
391function anchorIsValid(anchor) {
392  if (/[\x00-\x19\s,[\]{}]/.test(anchor)) {
393    const sa = JSON.stringify(anchor);
394    const msg = `Anchor must not contain whitespace or control characters: ${sa}`;
395    throw new Error(msg);
396  }
397  return true;
398}
399function anchorNames(root) {
400  const anchors = /* @__PURE__ */ new Set();
401  visit(root, {
402    Value(_key, node) {
403      if (node.anchor)
404        anchors.add(node.anchor);
405    }
406  });
407  return anchors;
408}
409function findNewAnchor(prefix, exclude) {
410  for (let i = 1; true; ++i) {
411    const name = `${prefix}${i}`;
412    if (!exclude.has(name))
413      return name;
414  }
415}
416function createNodeAnchors(doc, prefix) {
417  const aliasObjects = [];
418  const sourceObjects = /* @__PURE__ */ new Map();
419  let prevAnchors = null;
420  return {
421    onAnchor: (source) => {
422      aliasObjects.push(source);
423      prevAnchors ?? (prevAnchors = anchorNames(doc));
424      const anchor = findNewAnchor(prefix, prevAnchors);
425      prevAnchors.add(anchor);
426      return anchor;
427    },
428    /**
429     * With circular references, the source node is only resolved after all
430     * of its child nodes are. This is why anchors are set only after all of
431     * the nodes have been created.
432     */
433    setAnchors: () => {
434      for (const source of aliasObjects) {
435        const ref = sourceObjects.get(source);
436        if (typeof ref === "object" && ref.anchor && (isScalar(ref.node) || isCollection(ref.node))) {
437          ref.node.anchor = ref.anchor;
438        } else {
439          const error = new Error("Failed to resolve repeated object (this should not happen)");
440          error.source = source;
441          throw error;
442        }
443      }
444    },
445    sourceObjects
446  };
447}
448
449// node_modules/yaml/browser/dist/doc/applyReviver.js
450function applyReviver(reviver, obj, key, val) {
451  if (val && typeof val === "object") {
452    if (Array.isArray(val)) {
453      for (let i = 0, len = val.length; i < len; ++i) {
454        const v0 = val[i];
455        const v1 = applyReviver(reviver, val, String(i), v0);
456        if (v1 === void 0)
457          delete val[i];
458        else if (v1 !== v0)
459          val[i] = v1;
460      }
461    } else if (val instanceof Map) {
462      for (const k of Array.from(val.keys())) {
463        const v0 = val.get(k);
464        const v1 = applyReviver(reviver, val, k, v0);
465        if (v1 === void 0)
466          val.delete(k);
467        else if (v1 !== v0)
468          val.set(k, v1);
469      }
470    } else if (val instanceof Set) {
471      for (const v0 of Array.from(val)) {
472        const v1 = applyReviver(reviver, val, v0, v0);
473        if (v1 === void 0)
474          val.delete(v0);
475        else if (v1 !== v0) {
476          val.delete(v0);
477          val.add(v1);
478        }
479      }
480    } else {
481      for (const [k, v0] of Object.entries(val)) {
482        const v1 = applyReviver(reviver, val, k, v0);
483        if (v1 === void 0)
484          delete val[k];
485        else if (v1 !== v0)
486          val[k] = v1;
487      }
488    }
489  }
490  return reviver.call(obj, key, val);
491}
492
493// node_modules/yaml/browser/dist/nodes/toJS.js
494function toJS(value, arg, ctx) {
495  if (Array.isArray(value))
496    return value.map((v, i) => toJS(v, String(i), ctx));
497  if (value && typeof value.toJSON === "function") {
498    if (!ctx || !hasAnchor(value))
499      return value.toJSON(arg, ctx);
500    const data = { aliasCount: 0, count: 1, res: void 0 };
501    ctx.anchors.set(value, data);
502    ctx.onCreate = (res2) => {
503      data.res = res2;
504      delete ctx.onCreate;
505    };
506    const res = value.toJSON(arg, ctx);
507    if (ctx.onCreate)
508      ctx.onCreate(res);
509    return res;
510  }
511  if (typeof value === "bigint" && !ctx?.keep)
512    return Number(value);
513  return value;
514}
515
516// node_modules/yaml/browser/dist/nodes/Node.js
517var NodeBase = class {
518  constructor(type) {
519    Object.defineProperty(this, NODE_TYPE, { value: type });
520  }
521  /** Create a copy of this node.  */
522  clone() {
523    const copy = Object.create(Object.getPrototypeOf(this), Object.getOwnPropertyDescriptors(this));
524    if (this.range)
525      copy.range = this.range.slice();
526    return copy;
527  }
528  /** A plain JavaScript representation of this node. */
529  toJS(doc, { mapAsMap, maxAliasCount, onAnchor, reviver } = {}) {
530    if (!isDocument(doc))
531      throw new TypeError("A document argument is required");
532    const ctx = {
533      anchors: /* @__PURE__ */ new Map(),
534      doc,
535      keep: true,
536      mapAsMap: mapAsMap === true,
537      mapKeyWarned: false,
538      maxAliasCount: typeof maxAliasCount === "number" ? maxAliasCount : 100
539    };
540    const res = toJS(this, "", ctx);
541    if (typeof onAnchor === "function")
542      for (const { count, res: res2 } of ctx.anchors.values())
543        onAnchor(res2, count);
544    return typeof reviver === "function" ? applyReviver(reviver, { "": res }, "", res) : res;
545  }
546};
547
548// node_modules/yaml/browser/dist/nodes/Alias.js
549var Alias = class extends NodeBase {
550  constructor(source) {
551    super(ALIAS);
552    this.source = source;
553    Object.defineProperty(this, "tag", {
554      set() {
555        throw new Error("Alias nodes cannot have tags");
556      }
557    });
558  }
559  /**
560   * Resolve the value of this alias within `doc`, finding the last
561   * instance of the `source` anchor before this node.
562   */
563  resolve(doc, ctx) {
564    if (ctx?.maxAliasCount === 0)
565      throw new ReferenceError("Alias resolution is disabled");
566    let nodes;
567    if (ctx?.aliasResolveCache) {
568      nodes = ctx.aliasResolveCache;
569    } else {
570      nodes = [];
571      visit(doc, {
572        Node: (_key, node) => {
573          if (isAlias(node) || hasAnchor(node))
574            nodes.push(node);
575        }
576      });
577      if (ctx)
578        ctx.aliasResolveCache = nodes;
579    }
580    let found = void 0;
581    for (const node of nodes) {
582      if (node === this)
583        break;
584      if (node.anchor === this.source)
585        found = node;
586    }
587    if (found && ctx) {
588      const { anchors, doc: doc2, maxAliasCount } = ctx;
589      let data = anchors.get(found);
590      if (!data) {
591        toJS(found, null, ctx);
592        data = anchors.get(found);
593      }
594      if (data?.res === void 0) {
595        const msg = "This should not happen: Alias anchor was not resolved?";
596        throw new ReferenceError(msg);
597      }
598      if (maxAliasCount >= 0) {
599        data.count += 1;
600        if (data.aliasCount === 0)
601          data.aliasCount = getAliasCount(doc2, found, anchors);
602        if (data.count * data.aliasCount > maxAliasCount) {
603          const msg = "Excessive alias count indicates a resource exhaustion attack";
604          throw new ReferenceError(msg);
605        }
606      }
607    }
608    return found;
609  }
610  toJSON(_arg, ctx) {
611    if (!ctx)
612      return { source: this.source };
613    const source = this.resolve(ctx.doc, ctx);
614    if (!source) {
615      const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`;
616      throw new ReferenceError(msg);
617    }
618    return ctx.anchors.get(source).res;
619  }
620  toString(ctx, _onComment, _onChompKeep) {
621    const src = `*${this.source}`;
622    if (ctx) {
623      anchorIsValid(this.source);
624      if (ctx.options.verifyAliasOrder && !ctx.anchors.has(this.source)) {
625        const msg = `Unresolved alias (the anchor must be set before the alias): ${this.source}`;
626        throw new Error(msg);
627      }
628      if (ctx.implicitKey)
629        return `${src} `;
630    }
631    return src;
632  }
633};
634function getAliasCount(doc, node, anchors) {
635  if (isAlias(node)) {
636    const source = node.resolve(doc);
637    const anchor = anchors && source && anchors.get(source);
638    return anchor ? anchor.count * anchor.aliasCount : 0;
639  } else if (isCollection(node)) {
640    let count = 0;
641    for (const item of node.items) {
642      const c = getAliasCount(doc, item, anchors);
643      if (c > count)
644        count = c;
645    }
646    return count;
647  } else if (isPair(node)) {
648    const kc = getAliasCount(doc, node.key, anchors);
649    const vc = getAliasCount(doc, node.value, anchors);
650    return Math.max(kc, vc);
651  }
652  return 1;
653}
654
655// node_modules/yaml/browser/dist/nodes/Scalar.js
656var isScalarValue = (value) => !value || typeof value !== "function" && typeof value !== "object";
657var Scalar = class extends NodeBase {
658  constructor(value) {
659    super(SCALAR);
660    this.value = value;
661  }
662  toJSON(arg, ctx) {
663    return ctx?.keep ? this.value : toJS(this.value, arg, ctx);
664  }
665  toString() {
666    return String(this.value);
667  }
668};
669Scalar.BLOCK_FOLDED = "BLOCK_FOLDED";
670Scalar.BLOCK_LITERAL = "BLOCK_LITERAL";
671Scalar.PLAIN = "PLAIN";
672Scalar.QUOTE_DOUBLE = "QUOTE_DOUBLE";
673Scalar.QUOTE_SINGLE = "QUOTE_SINGLE";
674
675// node_modules/yaml/browser/dist/doc/createNode.js
676var defaultTagPrefix = "tag:yaml.org,2002:";
677function findTagObject(value, tagName, tags) {
678  if (tagName) {
679    const match = tags.filter((t) => t.tag === tagName);
680    const tagObj = match.find((t) => !t.format) ?? match[0];
681    if (!tagObj)
682      throw new Error(`Tag ${tagName} not found`);
683    return tagObj;
684  }
685  return tags.find((t) => t.identify?.(value) && !t.format);
686}
687function createNode(value, tagName, ctx) {
688  if (isDocument(value))
689    value = value.contents;
690  if (isNode(value))
691    return value;
692  if (isPair(value)) {
693    const map2 = ctx.schema[MAP].createNode?.(ctx.schema, null, ctx);
694    map2.items.push(value);
695    return map2;
696  }
697  if (value instanceof String || value instanceof Number || value instanceof Boolean || typeof BigInt !== "undefined" && value instanceof BigInt) {
698    value = value.valueOf();
699  }
700  const { aliasDuplicateObjects, onAnchor, onTagObj, schema: schema4, sourceObjects } = ctx;
701  let ref = void 0;
702  if (aliasDuplicateObjects && value && typeof value === "object") {
703    ref = sourceObjects.get(value);
704    if (ref) {
705      ref.anchor ?? (ref.anchor = onAnchor(value));
706      return new Alias(ref.anchor);
707    } else {
708      ref = { anchor: null, node: null };
709      sourceObjects.set(value, ref);
710    }
711  }
712  if (tagName?.startsWith("!!"))
713    tagName = defaultTagPrefix + tagName.slice(2);
714  let tagObj = findTagObject(value, tagName, schema4.tags);
715  if (!tagObj) {
716    if (value && typeof value.toJSON === "function") {
717      value = value.toJSON();
718    }
719    if (!value || typeof value !== "object") {
720      const node2 = new Scalar(value);
721      if (ref)
722        ref.node = node2;
723      return node2;
724    }
725    tagObj = value instanceof Map ? schema4[MAP] : Symbol.iterator in Object(value) ? schema4[SEQ] : schema4[MAP];
726  }
727  if (onTagObj) {
728    onTagObj(tagObj);
729    delete ctx.onTagObj;
730  }
731  const node = tagObj?.createNode ? tagObj.createNode(ctx.schema, value, ctx) : typeof tagObj?.nodeClass?.from === "function" ? tagObj.nodeClass.from(ctx.schema, value, ctx) : new Scalar(value);
732  if (tagName)
733    node.tag = tagName;
734  else if (!tagObj.default)
735    node.tag = tagObj.tag;
736  if (ref)
737    ref.node = node;
738  return node;
739}
740
741// node_modules/yaml/browser/dist/nodes/Collection.js
742function collectionFromPath(schema4, path, value) {
743  let v = value;
744  for (let i = path.length - 1; i >= 0; --i) {
745    const k = path[i];
746    if (typeof k === "number" && Number.isInteger(k) && k >= 0) {
747      const a = [];
748      a[k] = v;
749      v = a;
750    } else {
751      v = /* @__PURE__ */ new Map([[k, v]]);
752    }
753  }
754  return createNode(v, void 0, {
755    aliasDuplicateObjects: false,
756    keepUndefined: false,
757    onAnchor: () => {
758      throw new Error("This should not happen, please report a bug.");
759    },
760    schema: schema4,
761    sourceObjects: /* @__PURE__ */ new Map()
762  });
763}
764var isEmptyPath = (path) => path == null || typeof path === "object" && !!path[Symbol.iterator]().next().done;
765var Collection = class extends NodeBase {
766  constructor(type, schema4) {
767    super(type);
768    Object.defineProperty(this, "schema", {
769      value: schema4,
770      configurable: true,
771      enumerable: false,
772      writable: true
773    });
774  }
775  /**
776   * Create a copy of this collection.
777   *
778   * @param schema - If defined, overwrites the original's schema
779   */
780  clone(schema4) {
781    const copy = Object.create(Object.getPrototypeOf(this), Object.getOwnPropertyDescriptors(this));
782    if (schema4)
783      copy.schema = schema4;
784    copy.items = copy.items.map((it) => isNode(it) || isPair(it) ? it.clone(schema4) : it);
785    if (this.range)
786      copy.range = this.range.slice();
787    return copy;
788  }
789  /**
790   * Adds a value to the collection. For `!!map` and `!!omap` the value must
791   * be a Pair instance or a `{ key, value }` object, which may not have a key
792   * that already exists in the map.
793   */
794  addIn(path, value) {
795    if (isEmptyPath(path))
796      this.add(value);
797    else {
798      const [key, ...rest] = path;
799      const node = this.get(key, true);
800      if (isCollection(node))
801        node.addIn(rest, value);
802      else if (node === void 0 && this.schema)
803        this.set(key, collectionFromPath(this.schema, rest, value));
804      else
805        throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`);
806    }
807  }
808  /**
809   * Removes a value from the collection.
810   * @returns `true` if the item was found and removed.
811   */
812  deleteIn(path) {
813    const [key, ...rest] = path;
814    if (rest.length === 0)
815      return this.delete(key);
816    const node = this.get(key, true);
817    if (isCollection(node))
818      return node.deleteIn(rest);
819    else
820      throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`);
821  }
822  /**
823   * Returns item at `key`, or `undefined` if not found. By default unwraps
824   * scalar values from their surrounding node; to disable set `keepScalar` to
825   * `true` (collections are always returned intact).
826   */
827  getIn(path, keepScalar) {
828    const [key, ...rest] = path;
829    const node = this.get(key, true);
830    if (rest.length === 0)
831      return !keepScalar && isScalar(node) ? node.value : node;
832    else
833      return isCollection(node) ? node.getIn(rest, keepScalar) : void 0;
834  }
835  hasAllNullValues(allowScalar) {
836    return this.items.every((node) => {
837      if (!isPair(node))
838        return false;
839      const n = node.value;
840      return n == null || allowScalar && isScalar(n) && n.value == null && !n.commentBefore && !n.comment && !n.tag;
841    });
842  }
843  /**
844   * Checks if the collection includes a value with the key `key`.
845   */
846  hasIn(path) {
847    const [key, ...rest] = path;
848    if (rest.length === 0)
849      return this.has(key);
850    const node = this.get(key, true);
851    return isCollection(node) ? node.hasIn(rest) : false;
852  }
853  /**
854   * Sets a value in this collection. For `!!set`, `value` needs to be a
855   * boolean to add/remove the item from the set.
856   */
857  setIn(path, value) {
858    const [key, ...rest] = path;
859    if (rest.length === 0) {
860      this.set(key, value);
861    } else {
862      const node = this.get(key, true);
863      if (isCollection(node))
864        node.setIn(rest, value);
865      else if (node === void 0 && this.schema)
866        this.set(key, collectionFromPath(this.schema, rest, value));
867      else
868        throw new Error(`Expected YAML collection at ${key}. Remaining path: ${rest}`);
869    }
870  }
871};
872
873// node_modules/yaml/browser/dist/stringify/stringifyComment.js
874var stringifyComment = (str) => str.replace(/^(?!$)(?: $)?/gm, "#");
875function indentComment(comment, indent) {
876  if (/^\n+$/.test(comment))
877    return comment.substring(1);
878  return indent ? comment.replace(/^(?! *$)/gm, indent) : comment;
879}
880var lineComment = (str, indent, comment) => str.endsWith("\n") ? indentComment(comment, indent) : comment.includes("\n") ? "\n" + indentComment(comment, indent) : (str.endsWith(" ") ? "" : " ") + comment;
881
882// node_modules/yaml/browser/dist/stringify/foldFlowLines.js
883var FOLD_FLOW = "flow";
884var FOLD_BLOCK = "block";
885var FOLD_QUOTED = "quoted";
886function foldFlowLines(text, indent, mode = "flow", { indentAtStart, lineWidth = 80, minContentWidth = 20, onFold, onOverflow } = {}) {
887  if (!lineWidth || lineWidth < 0)
888    return text;
889  if (lineWidth < minContentWidth)
890    minContentWidth = 0;
891  const endStep = Math.max(1 + minContentWidth, 1 + lineWidth - indent.length);
892  if (text.length <= endStep)
893    return text;
894  const folds = [];
895  const escapedFolds = {};
896  let end = lineWidth - indent.length;
897  if (typeof indentAtStart === "number") {
898    if (indentAtStart > lineWidth - Math.max(2, minContentWidth))
899      folds.push(0);
900    else
901      end = lineWidth - indentAtStart;
902  }
903  let split = void 0;
904  let prev = void 0;
905  let overflow = false;
906  let i = -1;
907  let escStart = -1;
908  let escEnd = -1;
909  if (mode === FOLD_BLOCK) {
910    i = consumeMoreIndentedLines(text, i, indent.length);
911    if (i !== -1)
912      end = i + endStep;
913  }
914  for (let ch; ch = text[i += 1]; ) {
915    if (mode === FOLD_QUOTED && ch === "\\") {
916      escStart = i;
917      switch (text[i + 1]) {
918        case "x":
919          i += 3;
920          break;
921        case "u":
922          i += 5;
923          break;
924        case "U":
925          i += 9;
926          break;
927        default:
928          i += 1;
929      }
930      escEnd = i;
931    }
932    if (ch === "\n") {
933      if (mode === FOLD_BLOCK)
934        i = consumeMoreIndentedLines(text, i, indent.length);
935      end = i + indent.length + endStep;
936      split = void 0;
937    } else {
938      if (ch === " " && prev && prev !== " " && prev !== "\n" && prev !== "	") {
939        const next = text[i + 1];
940        if (next && next !== " " && next !== "\n" && next !== "	")
941          split = i;
942      }
943      if (i >= end) {
944        if (split) {
945          folds.push(split);
946          end = split + endStep;
947          split = void 0;
948        } else if (mode === FOLD_QUOTED) {
949          while (prev === " " || prev === "	") {
950            prev = ch;
951            ch = text[i += 1];
952            overflow = true;
953          }
954          const j = i > escEnd + 1 ? i - 2 : escStart - 1;
955          if (escapedFolds[j])
956            return text;
957          folds.push(j);
958          escapedFolds[j] = true;
959          end = j + endStep;
960          split = void 0;
961        } else {
962          overflow = true;
963        }
964      }
965    }
966    prev = ch;
967  }
968  if (overflow && onOverflow)
969    onOverflow();
970  if (folds.length === 0)
971    return text;
972  if (onFold)
973    onFold();
974  let res = text.slice(0, folds[0]);
975  for (let i2 = 0; i2 < folds.length; ++i2) {
976    const fold = folds[i2];
977    const end2 = folds[i2 + 1] || text.length;
978    if (fold === 0)
979      res = `
980${indent}${text.slice(0, end2)}`;
981    else {
982      if (mode === FOLD_QUOTED && escapedFolds[fold])
983        res += `${text[fold]}\\`;
984      res += `
985${indent}${text.slice(fold + 1, end2)}`;
986    }
987  }
988  return res;
989}
990function consumeMoreIndentedLines(text, i, indent) {
991  let end = i;
992  let start = i + 1;
993  let ch = text[start];
994  while (ch === " " || ch === "	") {
995    if (i < start + indent) {
996      ch = text[++i];
997    } else {
998      do {
999        ch = text[++i];
1000      } while (ch && ch !== "\n");
1001      end = i;
1002      start = i + 1;
1003      ch = text[start];
1004    }
1005  }
1006  return end;
1007}
1008
1009// node_modules/yaml/browser/dist/stringify/stringifyString.js
1010var getFoldOptions = (ctx, isBlock2) => ({
1011  indentAtStart: isBlock2 ? ctx.indent.length : ctx.indentAtStart,
1012  lineWidth: ctx.options.lineWidth,
1013  minContentWidth: ctx.options.minContentWidth
1014});
1015var containsDocumentMarker = (str) => /^(%|---|\.\.\.)/m.test(str);
1016function lineLengthOverLimit(str, lineWidth, indentLength) {
1017  if (!lineWidth || lineWidth < 0)
1018    return false;
1019  const limit = lineWidth - indentLength;
1020  const strLen = str.length;
1021  if (strLen <= limit)
1022    return false;
1023  for (let i = 0, start = 0; i < strLen; ++i) {
1024    if (str[i] === "\n") {
1025      if (i - start > limit)
1026        return true;
1027      start = i + 1;
1028      if (strLen - start <= limit)
1029        return false;
1030    }
1031  }
1032  return true;
1033}
1034function doubleQuotedString(value, ctx) {
1035  const json = JSON.stringify(value);
1036  if (ctx.options.doubleQuotedAsJSON)
1037    return json;
1038  const { implicitKey } = ctx;
1039  const minMultiLineLength = ctx.options.doubleQuotedMinMultiLineLength;
1040  const indent = ctx.indent || (containsDocumentMarker(value) ? "  " : "");
1041  let str = "";
1042  let start = 0;
1043  for (let i = 0, ch = json[i]; ch; ch = json[++i]) {
1044    if (ch === " " && json[i + 1] === "\\" && json[i + 2] === "n") {
1045      str += json.slice(start, i) + "\\ ";
1046      i += 1;
1047      start = i;
1048      ch = "\\";
1049    }
1050    if (ch === "\\")
1051      switch (json[i + 1]) {
1052        case "u":
1053          {
1054            str += json.slice(start, i);
1055            const code = json.substr(i + 2, 4);
1056            switch (code) {
1057              case "0000":
1058                str += "\\0";
1059                break;
1060              case "0007":
1061                str += "\\a";
1062                break;
1063              case "000b":
1064                str += "\\v";
1065                break;
1066              case "001b":
1067                str += "\\e";
1068                break;
1069              case "0085":
1070                str += "\\N";
1071                break;
1072              case "00a0":
1073                str += "\\_";
1074                break;
1075              case "2028":
1076                str += "\\L";
1077                break;
1078              case "2029":
1079                str += "\\P";
1080                break;
1081              default:
1082                if (code.substr(0, 2) === "00")
1083                  str += "\\x" + code.substr(2);
1084                else
1085                  str += json.substr(i, 6);
1086            }
1087            i += 5;
1088            start = i + 1;
1089          }
1090          break;
1091        case "n":
1092          if (implicitKey || json[i + 2] === '"' || json.length < minMultiLineLength) {
1093            i += 1;
1094          } else {
1095            str += json.slice(start, i) + "\n\n";
1096            while (json[i + 2] === "\\" && json[i + 3] === "n" && json[i + 4] !== '"') {
1097              str += "\n";
1098              i += 2;
1099            }
1100            str += indent;
1101            if (json[i + 2] === " ")
1102              str += "\\";
1103            i += 1;
1104            start = i + 1;
1105          }
1106          break;
1107        default:
1108          i += 1;
1109      }
1110  }
1111  str = start ? str + json.slice(start) : json;
1112  return implicitKey ? str : foldFlowLines(str, indent, FOLD_QUOTED, getFoldOptions(ctx, false));
1113}
1114function singleQuotedString(value, ctx) {
1115  if (ctx.options.singleQuote === false || ctx.implicitKey && value.includes("\n") || /[ \t]\n|\n[ \t]/.test(value))
1116    return doubleQuotedString(value, ctx);
1117  const indent = ctx.indent || (containsDocumentMarker(value) ? "  " : "");
1118  const res = "'" + value.replace(/'/g, "''").replace(/\n+/g, `$&
1119${indent}`) + "'";
1120  return ctx.implicitKey ? res : foldFlowLines(res, indent, FOLD_FLOW, getFoldOptions(ctx, false));
1121}
1122function quotedString(value, ctx) {
1123  const { singleQuote } = ctx.options;
1124  let qs;
1125  if (singleQuote === false)
1126    qs = doubleQuotedString;
1127  else {
1128    const hasDouble = value.includes('"');
1129    const hasSingle = value.includes("'");
1130    if (hasDouble && !hasSingle)
1131      qs = singleQuotedString;
1132    else if (hasSingle && !hasDouble)
1133      qs = doubleQuotedString;
1134    else
1135      qs = singleQuote ? singleQuotedString : doubleQuotedString;
1136  }
1137  return qs(value, ctx);
1138}
1139var blockEndNewlines;
1140try {
1141  blockEndNewlines = new RegExp("(^|(?<!\n))\n+(?!\n|$)", "g");
1142} catch {
1143  blockEndNewlines = /\n+(?!\n|$)/g;
1144}
1145function blockString({ comment, type, value }, ctx, onComment, onChompKeep) {
1146  const { blockQuote, commentString, lineWidth } = ctx.options;
1147  if (!blockQuote || /\n[\t ]+$/.test(value)) {
1148    return quotedString(value, ctx);
1149  }
1150  const indent = ctx.indent || (ctx.forceBlockIndent || containsDocumentMarker(value) ? "  " : "");
1151  const literal = blockQuote === "literal" ? true : blockQuote === "folded" || type === Scalar.BLOCK_FOLDED ? false : type === Scalar.BLOCK_LITERAL ? true : !lineLengthOverLimit(value, lineWidth, indent.length);
1152  if (!value)
1153    return literal ? "|\n" : ">\n";
1154  let chomp;
1155  let endStart;
1156  for (endStart = value.length; endStart > 0; --endStart) {
1157    const ch = value[endStart - 1];
1158    if (ch !== "\n" && ch !== "	" && ch !== " ")
1159      break;
1160  }
1161  let end = value.substring(endStart);
1162  const endNlPos = end.indexOf("\n");
1163  if (endNlPos === -1) {
1164    chomp = "-";
1165  } else if (value === end || endNlPos !== end.length - 1) {
1166    chomp = "+";
1167    if (onChompKeep)
1168      onChompKeep();
1169  } else {
1170    chomp = "";
1171  }
1172  if (end) {
1173    value = value.slice(0, -end.length);
1174    if (end[end.length - 1] === "\n")
1175      end = end.slice(0, -1);
1176    end = end.replace(blockEndNewlines, `$&${indent}`);
1177  }
1178  let startWithSpace = false;
1179  let startEnd;
1180  let startNlPos = -1;
1181  for (startEnd = 0; startEnd < value.length; ++startEnd) {
1182    const ch = value[startEnd];
1183    if (ch === " ")
1184      startWithSpace = true;
1185    else if (ch === "\n")
1186      startNlPos = startEnd;
1187    else
1188      break;
1189  }
1190  let start = value.substring(0, startNlPos < startEnd ? startNlPos + 1 : startEnd);
1191  if (start) {
1192    value = value.substring(start.length);
1193    start = start.replace(/\n+/g, `$&${indent}`);
1194  }
1195  const indentSize = indent ? "2" : "1";
1196  let header = (startWithSpace ? indentSize : "") + chomp;
1197  if (comment) {
1198    header += " " + commentString(comment.replace(/ ?[\r\n]+/g, " "));
1199    if (onComment)
1200      onComment();
hooks/config.ts 430 lines
1import type {
2  Config,
3  Health,
4  PortDef,
5  RestartPolicy,
6  ServiceDef,
7  SetupStep,
8} from '../types'
9
10export const CONFIG_FILES = ['canopy.yaml', 'canopy.yml'] as const
11export const LAUNCH_FILE = '.claude/launch.json'
12
13export const STARTER = `# canopy: the processes this project runs, per worktree.
14version: 1
15
16ports:
17  web:
18    preferred: 3000
19
20# env:
21#   API_URL: http://127.0.0.1:\${ports.web}
22
23# Files copied from the main checkout into a new worktree.
24# copy:
25#   - .env
26
27# Run once in a new worktree.
28# setup:
29#   - npm install
30
31services:
32  web:
33    run: npm run dev -- --port \${ports.web}
34    ports: [web]
35    health:
36      http: http://127.0.0.1:\${ports.web}/
37`
38
39export function emptyConfig(): Config {
40  return {
41    source: null,
42    ports: {},
43    env: {},
44    setup: [],
45    copy: [],
46    services: [],
47    errors: [],
48  }
49}
50
51type Dict = Record<string, unknown>
52
53const isDict = (value: unknown): value is Dict =>
54  typeof value === 'object' && value !== null && !Array.isArray(value)
55
56const asText = (value: unknown): string | null =>
57  typeof value === 'string'
58    ? value
59    : typeof value === 'number' || typeof value === 'boolean'
60      ? String(value)
61      : null
62
63function asTextMap(value: unknown): Record<string, string> {
64  const map: Record<string, string> = {}
65
66  if (isDict(value)) {
67    for (const [key, entry] of Object.entries(value)) {
68      const text = asText(entry)
69
70      if (text !== null) {
71        map[key] = text
72      }
73    }
74  }
75
76  return map
77}
78
79const asTextList = (value: unknown): string[] =>
80  Array.isArray(value)
81    ? value.map(asText).filter((entry): entry is string => entry !== null)
82    : []
83
84// `3s`, `500ms`, `1m`, or a bare number of seconds.
85export function durationMs(value: unknown, fallback: number): number {
86  if (typeof value === 'number' && Number.isFinite(value)) {
87    return value * 1000
88  }
89
90  const match = typeof value === 'string' ? /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(value.trim()) : null
91
92  if (match === null) {
93    return fallback
94  }
95
96  const amount = Number(match[1])
97  const unit = match[2] ?? 's'
98
99  return unit === 'ms' ? amount : unit === 'm' ? amount * 60_000 : amount * 1000
100}
101
102const PORT_REF = /\$\{ports\.([A-Za-z0-9_-]+)\}/g
103
104function portRefs(texts: readonly string[]): string[] {
105  const names = new Set<string>()
106
107  for (const text of texts) {
108    for (const match of text.matchAll(PORT_REF)) {
109      if (match[1] !== undefined) {
110        names.add(match[1])
111      }
112    }
113  }
114
115  return [...names]
116}
117
118function readPort(value: unknown): PortDef {
119  if (typeof value === 'number') {
120    return { preferred: value, range: null }
121  }
122
123  const dict = isDict(value) ? value : {}
124  const range = dict.range
125  const hasRange =
126    Array.isArray(range) && typeof range[0] === 'number' && typeof range[1] === 'number'
127
128  return {
129    preferred: typeof dict.preferred === 'number' ? dict.preferred : null,
130    range: hasRange ? [range[0] as number, range[1] as number] : null,
131  }
132}
133
134function readHealth(value: unknown): Health | null {
135  if (!isDict(value)) {
136    return null
137  }
138
139  const http = asText(value.http)
140  const tcp = asText(value.tcp)
141
142  if (http === null && tcp === null) {
143    return null
144  }
145
146  return {
147    http,
148    tcp,
149    intervalMs: durationMs(value.interval, 3000),
150    retries: typeof value.retries === 'number' ? value.retries : 10,
151    startMs: durationMs(value.start_period, 0),
152  }
153}
154
155const RESTARTS: readonly RestartPolicy[] = ['never', 'on-failure', 'always']
156
157function readService(name: string, value: unknown, errors: string[]): ServiceDef | null {
158  const dict = isDict(value) ? value : {}
159  const run = asText(dict.run)
160
161  if (run === null || run.trim() === '') {
162    errors.push(`services.${name}: no \`run\` command`)
163
164    return null
165  }
166
167  if (dict.runtime !== undefined && dict.runtime !== 'host') {
168    errors.push(`services.${name}: runtime ${String(dict.runtime)} is not supported, it runs on the host`)
169  }
170
171  const env = asTextMap(dict.env)
172  const health = readHealth(dict.health)
173  const restart = RESTARTS.find(policy => policy === dict.restart) ?? 'on-failure'
174  const declared = asTextList(dict.ports)
175  const referenced = portRefs([
176    run,
177    ...Object.values(env),
178    health?.http ?? '',
179    health?.tcp ?? '',
180  ])
181
182  return {
183    name,
184    run,
185    cwd: asText(dict.cwd) ?? '.',
186    env,
187    ports: declared.length > 0 ? declared : referenced,
188    dependsOn: asTextList(dict.depends_on),
189    health,
190    restart,
191    autostart: dict.autostart !== false,
192  }
193}
194
195function readSetup(value: unknown): SetupStep[] {
196  if (!Array.isArray(value)) {
197    return []
198  }
199
200  const steps: SetupStep[] = []
201
202  for (const entry of value) {
203    const run = isDict(entry) ? asText(entry.run) : asText(entry)
204
205    if (run !== null && run.trim() !== '') {
206      const dict = isDict(entry) ? entry : {}
207
208      steps.push({
209        name: asText(dict.name) ?? run,
210        run,
211        cwd: asText(dict.cwd) ?? '.',
212      })
213    }
214  }
215
216  return steps
217}
218
219// Reads a parsed canopy.yaml. Never throws: what it cannot use becomes an
220// entry of `errors`, and the rest still loads.
221export function readConfig(raw: unknown, source: string): Config {
222  const config = emptyConfig()
223  config.source = source
224
225  if (!isDict(raw)) {
226    config.errors.push(`${source}: not a mapping`)
227
228    return config
229  }
230
231  if (isDict(raw.ports)) {
232    for (const [name, value] of Object.entries(raw.ports)) {
233      config.ports[name] = readPort(value)
234    }
235  }
236
237  config.env = asTextMap(raw.env)
238  config.setup = readSetup(raw.setup)
239  config.copy = asTextList(raw.copy)
240
241  if (isDict(raw.services)) {
242    for (const [name, value] of Object.entries(raw.services)) {
243      const service = readService(name, value, config.errors)
244
245      if (service !== null) {
246        config.services.push(service)
247      }
248    }
249  }
250
251  if (raw.databases !== undefined) {
252    config.errors.push('databases: not supported here, skipped')
253  }
254
255  const names = new Set(config.services.map(service => service.name))
256
257  for (const service of config.services) {
258    for (const port of service.ports) {
259      if (config.ports[port] === undefined) {
260        config.ports[port] = { preferred: null, range: null }
261      }
262    }
263
264    for (const needed of service.dependsOn) {
265      if (!names.has(needed)) {
266        config.errors.push(`services.${service.name}: depends_on unknown service ${needed}`)
267      }
268    }
269  }
270
271  if (startOrder(config.services, [...names]).cycle !== null) {
272    config.errors.push('services: depends_on has a cycle')
273  }
274
275  return config
276}
277
278// Reads Claude Code Desktop's own `.claude/launch.json` as services, for a
279// project that has no canopy.yaml.
280export function readLaunchJson(raw: unknown): Config {
281  const config = emptyConfig()
282  config.source = LAUNCH_FILE
283
284  const list = isDict(raw) && Array.isArray(raw.configurations) ? raw.configurations : []
285
286  for (const entry of list) {
287    if (!isDict(entry)) {
288      continue
289    }
290
291    const name = asText(entry.name)
292    const program = asText(entry.program)
293    const executable = asText(entry.runtimeExecutable) ?? (program === null ? null : 'node')
294
295    if (name === null || executable === null) {
296      continue
297    }
298
299    const args = [
300      ...asTextList(entry.runtimeArgs),
301      ...(program === null ? [] : [program]),
302      ...asTextList(entry.args),
303    ]
304    const hasPort = typeof entry.port === 'number'
305
306    if (hasPort) {
307      config.ports[name] = { preferred: entry.port as number, range: null }
308    }
309
310    config.services.push({
311      name,
312      run: [executable, ...args].join(' '),
313      cwd: asText(entry.cwd) ?? '.',
314      env: asTextMap(entry.env),
315      ports: hasPort ? [name] : [],
316      dependsOn: [],
317      health: hasPort
318        ? {
319            http: null,
320            tcp: `127.0.0.1:\${ports.${name}}`,
321            intervalMs: 3000,
322            retries: 10,
323            startMs: 0,
324          }
325        : null,
326      restart: 'never',
327      autostart: true,
328    })
329  }
330
331  return config
332}
333
334// The wanted services and everything they depend on, dependencies first.
335export function startOrder(
336  services: readonly ServiceDef[],
337  wanted: readonly string[],
338): { order: string[]; cycle: string | null } {
339  const byName = new Map(services.map(service => [service.name, service]))
340  const order: string[] = []
341  const done = new Set<string>()
342  const open = new Set<string>()
343  let cycle: string | null = null
344
345  const visit = (name: string) => {
346    const service = byName.get(name)
347
348    if (service === undefined || done.has(name)) {
349      return
350    }
351
352    if (open.has(name)) {
353      cycle = name
354
355      return
356    }
357
358    open.add(name)
359    service.dependsOn.forEach(visit)
360    open.delete(name)
361    done.add(name)
362    order.push(name)
363  }
364
365  wanted.forEach(visit)
366
367  return { order, cycle }
368}
369
370export type Scope = {
371  ports: Record<string, number>
372  worktree: { path: string; name: string; branch: string }
373  env: Record<string, string>
374}
375
376// Fills `${ports.x}`, `${worktree.path|name|branch}` and `${env.X}`; a
377// reference it cannot fill is left as written.
378export function render(template: string, scope: Scope): string {
379  return template.replace(/\$\{([a-z]+)\.([A-Za-z0-9_-]+)\}/g, (whole, group: string, key: string) => {
380    const value =
381      group === 'ports'
382        ? scope.ports[key]
383        : group === 'worktree'
384          ? scope.worktree[key as keyof Scope['worktree']]
385          : group === 'env'
386            ? scope.env[key]
387            : undefined
388
389    return value === undefined ? whole : String(value)
390  })
391}
392
393// A small stable hash (FNV-1a), for a port offset and a file name.
394export function hash(text: string): number {
395  let value = 0x811c9dc5
396
397  for (let index = 0; index < text.length; index += 1) {
398    value ^= text.charCodeAt(index)
399    value = Math.imul(value, 0x01000193) >>> 0
400  }
401
402  return value
403}
404
405// The ports to try for one name in one worktree, best first: the main checkout
406// starts at the preferred port, every other worktree at a stable offset from it.
407export function portCandidates(
408  def: PortDef,
409  name: string,
410  worktreePath: string,
411  isMain: boolean,
412): number[] {
413  const [low, high] = def.range ?? [1024, 65535]
414  const base = def.preferred ?? def.range?.[0] ?? 4000 + (hash(name) % 1000)
415  const first = isMain ? base : base + 1 + (hash(worktreePath) % 200)
416  const span = Math.max(1, high - low + 1)
417  const candidates: number[] = []
418
419  for (let step = 0; step < 50; step += 1) {
420    const port = first + step
421    const wrapped = port > high || port < low ? low + ((port - low) % span + span) % span : port
422
423    if (!candidates.includes(wrapped)) {
424      candidates.push(wrapped)
425    }
426  }
427
428  return candidates
429}
430
hooks/git.ts 128 lines
1import type { ChangedFile } from '../types'
2
3export type Worktree = {
4  path: string
5  head: string | null
6  branch: string | null
7  isBare: boolean
8  isDetached: boolean
9}
10
11// Parses `git worktree list --porcelain`: blank-line separated records, the
12// first one the main working tree.
13export function parseWorktreeList(porcelain: string): Worktree[] {
14  const worktrees: Worktree[] = []
15
16  for (const record of porcelain.split(/\n\s*\n/)) {
17    let current: Worktree | null = null
18
19    for (const line of record.split('\n')) {
20      const space = line.indexOf(' ')
21      const key = space < 0 ? line : line.slice(0, space)
22      const value = space < 0 ? '' : line.slice(space + 1)
23
24      if (key === 'worktree') {
25        current = {
26          path: value,
27          head: null,
28          branch: null,
29          isBare: false,
30          isDetached: false,
31        }
32      } else if (current === null) {
33        continue
34      } else if (key === 'HEAD') {
35        current.head = value
36      } else if (key === 'branch') {
37        current.branch = value.replace(/^refs\/heads\//, '')
38      } else if (key === 'bare') {
39        current.isBare = true
40      } else if (key === 'detached') {
41        current.isDetached = true
42      }
43    }
44
45    if (current !== null) {
46      worktrees.push(current)
47    }
48  }
49
50  return worktrees
51}
52
53// Parses `git status --porcelain=v1 -uall`: two status letters, a space, the
54// path; a rename reads `old -> new` and is listed under its new path.
55export function parseStatus(porcelain: string): ChangedFile[] {
56  const files: ChangedFile[] = []
57
58  for (const line of porcelain.split('\n')) {
59    if (line.length < 4) {
60      continue
61    }
62
63    const code = line.slice(0, 2)
64    const rest = line.slice(3)
65    const arrow = rest.indexOf(' -> ')
66    const path = code.includes('R') && arrow >= 0 ? rest.slice(arrow + 4) : rest
67
68    files.push({ code, path: unquote(path) })
69  }
70
71  return files
72}
73
74// Git quotes a path holding a space or a special character.
75function unquote(path: string): string {
76  if (!path.startsWith('"') || !path.endsWith('"')) {
77    return path
78  }
79
80  return path.slice(1, -1).replace(/\\(["\\])/g, '$1')
81}
82
83// Parses `git rev-list --left-right --count base...HEAD`: the left count is
84// what only the base has (behind), the right what only HEAD has (ahead).
85export function parseAheadBehind(counts: string): { ahead: number; behind: number } {
86  const [left, right] = counts.trim().split(/\s+/)
87  const behind = Number(left)
88  const ahead = Number(right)
89
90  return {
91    ahead: Number.isFinite(ahead) ? ahead : 0,
92    behind: Number.isFinite(behind) ? behind : 0,
93  }
94}
95
96// The last segment of a path: what a worktree is called on screen.
97export function baseName(path: string): string {
98  const trimmed = path.replace(/\/+$/, '')
99
100  return trimmed.slice(trimmed.lastIndexOf('/') + 1)
101}
102
103// The worktree a directory belongs to: the longest worktree path it is under.
104export function owningWorktree(paths: readonly string[], directory: string): string | null {
105  let best: string | null = null
106
107  for (const path of paths) {
108    const isInside = directory === path || directory.startsWith(`${path}/`)
109
110    if (isInside && (best === null || path.length > best.length)) {
111      best = path
112    }
113  }
114
115  return best
116}
117
118// A branch name git accepts and a folder name can carry.
119export function isValidWorktreeName(name: string): boolean {
120  return (
121    /^[A-Za-z0-9][A-Za-z0-9._/-]{0,63}$/.test(name) &&
122    !name.includes('..') &&
123    !name.endsWith('/') &&
124    !name.endsWith('.lock') &&
125    !name.includes('//')
126  )
127}
128
hooks/procs.ts 106 lines
1// Quotes one word for `sh`.
2export function shellQuote(text: string): string {
3  return `'${text.replace(/'/g, `'\\''`)}'`
4}
5
6export function dirName(path: string): string {
7  const cut = path.lastIndexOf('/')
8
9  return cut <= 0 ? '/' : path.slice(0, cut)
10}
11
12export function joinPath(base: string, relative: string): string {
13  if (relative.startsWith('/')) {
14    return relative
15  }
16
17  // `./a` and `a/` are `a`; a leading dot of a name (`.env`) is the name's own
18  const clean = relative.replace(/^(\.\/+)+/, '').replace(/\/+$/, '')
19
20  return clean === '' || clean === '.' ? base : `${base.replace(/\/+$/, '')}/${clean}`
21}
22
23// The shell line a service runs as: in its folder, with its environment,
24// once what it waits for answers, run again as its restart policy says, and
25// leaving its last exit code in `statusFile` so the mod can tell it ended.
26export function serviceCommand(spec: {
27  cwd: string
28  env: Record<string, string>
29  run: string
30  statusFile: string
31  label: string
32  waits: readonly string[]
33  restart: 'never' | 'on-failure' | 'always'
34  maxRestarts: number
35}): string {
36  const status = shellQuote(spec.statusFile)
37  const counter = shellQuote(`${spec.statusFile}.restarts`)
38  const exports = Object.entries(spec.env)
39    .filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
40    .map(([key, value]) => `export ${key}=${shellQuote(value)}; `)
41    .join('')
42  // two minutes for what it depends on, then it gives up with 124
43  const wait =
44    spec.waits.length === 0
45      ? ''
46      : `tries=0; until ${spec.waits.join(' && ')}; do tries=$((tries+1)); ` +
47        `if [ "$tries" -ge 240 ]; then echo 124 > ${status}; exit 124; fi; sleep 0.5; done; `
48  const again =
49    spec.restart === 'always' ? 'true' : spec.restart === 'on-failure' ? '[ "$code" -ne 0 ]' : 'false'
50
51  // a comment first: what the engine's own task lists show of the command
52  return (
53    `# ${spec.label.replace(/[\r\n]/g, ' ')}\n` +
54    `mkdir -p ${shellQuote(dirName(spec.statusFile))} && rm -f ${status} ${counter}; ` +
55    `if ! cd ${shellQuote(spec.cwd)}; then echo 127 > ${status}; exit 127; fi; ` +
56    exports +
57    wait +
58    `n=0; while :; do ( ${spec.run} ); code=$?; ${again} || break; n=$((n+1)); ` +
59    `[ "$n" -gt ${spec.maxRestarts} ] && break; echo "$n" > ${counter}; sleep "$n"; done; ` +
60    `echo "$code" > ${status}; exit "$code"`
61  )
62}
63
64// What the Bash tool says of a command it moved to the background.
65export function parseOutputFile(text: string): string | null {
66  return /written to: (\S+?\.output)/.exec(text)?.[1] ?? null
67}
68
69export type TaskEnd = { taskId: string; status: string }
70
71// The background tasks a notification row reports on.
72export function parseTaskNotifications(text: string): TaskEnd[] {
73  const ends: TaskEnd[] = []
74  const blocks = text.match(/<task-notification>[\s\S]*?<\/task-notification>/g) ?? []
75
76  for (const block of blocks) {
77    const taskId = /<task-id>\s*([^<\s]+)\s*<\/task-id>/.exec(block)?.[1]
78    const status = /<status>\s*([^<\s]+)\s*<\/status>/.exec(block)?.[1]
79
80    if (taskId !== undefined) {
81      ends.push({ taskId, status: status ?? 'ended' })
82    }
83  }
84
85  return ends
86}
87
88export function clip(text: string, max: number): string {
89  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
90}
91
92// `3m`, `2h`: how long ago, for a row.
93export function ago(nowMs: number, thenMs: number): string {
94  const seconds = Math.max(0, Math.round((nowMs - thenMs) / 1000))
95
96  if (seconds < 60) {
97    return `${seconds}s`
98  }
99
100  if (seconds < 3600) {
101    return `${Math.round(seconds / 60)}m`
102  }
103
104  return seconds < 86_400 ? `${Math.round(seconds / 3600)}h` : `${Math.round(seconds / 86_400)}d`
105}
106
types/index.d.ts 147 lines
1// The state contract: every value the mod keeps in `$.state`, and the shapes
2// its helpers share. Self-contained on purpose: no import, no reference.
3
4export type PortDef = {
5  preferred: number | null
6  range: [number, number] | null
7}
8
9export type Health = {
10  http: string | null
11  tcp: string | null
12  intervalMs: number
13  retries: number
14  startMs: number
15}
16
17export type RestartPolicy = 'never' | 'on-failure' | 'always'
18
19export type ServiceDef = {
20  name: string
21  run: string
22  cwd: string
23  env: Record<string, string>
24  ports: string[]
25  dependsOn: string[]
26  health: Health | null
27  restart: RestartPolicy
28  autostart: boolean
29}
30
31export type SetupStep = { name: string; run: string; cwd: string }
32
33export type Config = {
34  // The file it was read from (`canopy.yaml`, `.claude/launch.json`), or null.
35  source: string | null
36  ports: Record<string, PortDef>
37  env: Record<string, string>
38  setup: SetupStep[]
39  copy: string[]
40  services: ServiceDef[]
41  errors: string[]
42}
43
44export type PullRequest = { number: number; title: string; state: string }
45
46export type WorktreeInfo = {
47  path: string
48  name: string
49  branch: string | null
50  isMain: boolean
51  isCurrent: boolean
52  dirty: number
53  ahead: number
54  behind: number
55  lastSubject: string
56  lastWhen: string
57  pr: PullRequest | null
58}
59
60// Another Claude Code session with this mod, as its presence file says.
61export type Peer = {
62  sessionId: string
63  root: string
64  cwd: string
65  title: string
66  busy: boolean
67  at: number
68  ended: boolean
69  services: { worktree: string; name: string }[]
70}
71
72export type RepoState = {
73  root: string
74  current: string
75  base: string | null
76  worktrees: WorktreeInfo[]
77  configs: Record<string, Config>
78  peers: Peer[]
79  at: number
80}
81
82export type ServiceStatus =
83  | 'starting'
84  | 'running'
85  | 'healthy'
86  | 'unhealthy'
87  | 'stopped'
88  | 'exited'
89  | 'failed'
90
91// One service of one worktree, run as a Claude Code background task.
92export type ServiceRun = {
93  key: string
94  worktree: string
95  name: string
96  status: ServiceStatus
97  taskId: string | null
98  ports: Record<string, number>
99  outputFile: string | null
100  statusFile: string
101  startedAt: number
102  restarts: number
103  exitCode: number | null
104  detail: string
105}
106
107// A background command Claude started on its own, or a setup run.
108export type ClaudeProc = {
109  taskId: string
110  kind: 'claude' | 'setup'
111  command: string
112  root: string
113  startedAt: number
114  status: 'running' | 'completed' | 'failed' | 'killed' | 'ended'
115  outputFile: string | null
116}
117
118export type ChangedFile = { code: string; path: string }
119
120export type View =
121  | { kind: 'list' }
122  | { kind: 'new' }
123  | { kind: 'worktree'; path: string; tab: 'services' | 'changes' | 'procs' }
124  | { kind: 'diff'; path: string; file: string }
125  | { kind: 'logs'; path: string; title: string; file: string }
126
127export type StoppedNote = {
128  sessionId: string
129  services: { worktree: string; name: string }[]
130}
131
132declare module 'claude-code' {
133  interface PluginState {
134    canopy: {
135      repo: RepoState | null
136      view: View
137      services: Record<string, ServiceRun>
138      procs: ClaudeProc[]
139      changes: { path: string; files: ChangedFile[] } | null
140      diff: { file: string; patch: string } | null
141      log: { file: string; text: string } | null
142      stopped: StoppedNote | null
143      flash: string | null
144    }
145  }
146}
147