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

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:
canopy.yaml: started as Claude Code background tasks, each worktree on its own ports, with health checks, restarts and logs.copy and setup steps.Works in any git repository. It does not need the canopy app or canopyd.
| Type | What happens |
|---|---|
/canopy | Opens the pane. Number keys open a worktree; b goes back. |
/canopy status | Prints 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 setup | Runs the setup steps in the current worktree. |
/canopy trust | Allows 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.
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.
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.
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.
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.
hooks/register.tsx 2040 lines1import { 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 lines1var __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 lines1import 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}
430hooks/git.ts 128 lines1import 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}
128hooks/procs.ts 106 lines1// 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}
106types/index.d.ts 147 lines1// 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