@hybridlabor-api/aos 4.17.0 → 4.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.claude/hooks/aos-bus.mjs +8 -4
  2. package/.claude/hooks/go-gate.mjs +1 -1
  3. package/.claude/hooks/go-token.mjs +17 -2
  4. package/.claude/hooks/memb-inject.mjs +61 -37
  5. package/.codex-plugin/plugin.json +1 -1
  6. package/.opencode/commands/bdb-aos-plan.md +1 -1
  7. package/README.md +1 -0
  8. package/THIRD_PARTY_NOTICES.md +2 -2
  9. package/bin/aos-acp.mjs +27 -1
  10. package/bin/aos-doctor.mjs +36 -1
  11. package/bin/aos-uninstall.mjs +16 -2
  12. package/bin/go-check.mjs +79 -0
  13. package/bin/guarded-patterns.json +106 -0
  14. package/commands/plan.md +1 -1
  15. package/docs/codenotch.md +44 -0
  16. package/docs/codex-gate-smoke.md +43 -0
  17. package/docs/delegation-routing.md +32 -0
  18. package/docs/go-check.md +60 -0
  19. package/docs/master-session-acp.md +2 -0
  20. package/docs/opencode-setup.md +18 -0
  21. package/installer.js +155 -70
  22. package/lib/codenotch.js +389 -0
  23. package/lib/retired-skills.js +101 -0
  24. package/mcps/mcsc/README.md +1 -1
  25. package/mcps/mcsc/packages/core/src/adapters/agy.js +3 -1
  26. package/mcps/mcsc/packages/core/src/adapters/codex.js +2 -1
  27. package/mcps/mcsc/packages/core/src/adapters/opencode.js +2 -1
  28. package/mcps/mcsc/packages/core/src/depth.js +16 -0
  29. package/mcps/mcsc/packages/mcp/server.js +15 -2
  30. package/package.json +2 -2
  31. package/plugin-commands.json +1 -2
  32. package/plugin.json +1 -4
  33. package/plugins/bdb-aos-codex/.codex-plugin/plugin.json +1 -1
  34. package/plugins/bdb-aos-codex/skills/plan/SKILL.md +1 -1
  35. package/scripts/codex-gate-smoke.mjs +73 -0
  36. package/skills/basic/master-session/SKILL.md +11 -0
  37. package/skills/global_config/agenttrail/SKILL.md +3 -1
  38. package/skills/global_config/agenttrail/bin/agenttrail.mjs +255 -117
  39. package/skills/global_config/agenttrail/bin/ensure.mjs +60 -40
  40. package/skills/global_config/agenttrail/bin/repoid.mjs +70 -0
  41. package/skills/global_config/agenttrail/public/index.html +9 -1
  42. package/skills/global_config/aos-setup/scripts/aos-doctor.mjs +1 -1
  43. package/skills/global_config/bdb-memb-mcp/SKILL.md +5 -4
  44. package/skills/global_config/bdb-visual-edit/SKILL.md +28 -32
  45. package/skills/global_config/bdb-visual-edit/references/vite-react-source-attr.md +2 -2
  46. package/skills/global_config/bdb-visual-edit/scripts/locate-source.mjs +135 -0
  47. package/skills/global_config/bdb-visual-edit/scripts/sanitize-element.mjs +30 -2
  48. package/skills/global_config/mcsc/SKILL.md +9 -1
  49. package/skills/global_config/plan-arbiter/SKILL.md +1 -1
  50. package/skills/global_config/plan-canvas/SKILL.md +42 -4
  51. package/skills/global_config/plan-canvas/scripts/lib/plan-builder/README.md +1 -1
  52. package/skills/global_config/plan-canvas/scripts/lib/plan-builder/render.js +2 -2
  53. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/geometry.js +76 -0
  54. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/index.js +596 -0
  55. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/model.js +192 -0
  56. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-client/toolbar.js +99 -0
  57. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotate-server.js +282 -0
  58. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/annotation-schema.js +210 -0
  59. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/route.js +10 -0
  60. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/sdk.js +6 -230
  61. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/server.js +45 -4
  62. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/sessions.js +60 -21
  63. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/trail-on-approve.js +103 -0
  64. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/ui.js +19 -7
  65. package/skills/global_config/plan-canvas/scripts/plan-canvas.js +118 -20
  66. package/skills/global_config/subagent-setup/SKILL.md +6 -0
  67. package/skills/global_config/subagent-setup/scripts/setup-subagents.mjs +20 -1
  68. package/skills/playbooks/pb-idea-to-launch/SKILL.md +2 -2
  69. package/skills/playbooks/pb-redesign-app/SKILL.md +3 -3
  70. package/skills/playbooks/pb-release-aos/SKILL.md +2 -2
  71. package/skills/playbooks/pb-ship/SKILL.md +2 -2
  72. package/skills/playbooks/pb-worktrees-land/SKILL.md +2 -2
  73. package/skills/global_config/bdb-visual-edit/scripts/pick-snippet.js +0 -27
  74. package/skills/global_config/visual-edit/README.md +0 -96
  75. package/skills/global_config/visual-edit/SKILL.md +0 -615
  76. package/skills/global_config/visual-plan/README.md +0 -93
  77. package/skills/global_config/visual-plan/SKILL.md +0 -544
  78. package/skills/global_config/visual-plan/references/canvas.md +0 -139
  79. package/skills/global_config/visual-plan/references/connection.md +0 -51
  80. package/skills/global_config/visual-plan/references/document-quality.md +0 -186
  81. package/skills/global_config/visual-plan/references/exemplar.md +0 -62
  82. package/skills/global_config/visual-plan/references/local-files.md +0 -99
  83. package/skills/global_config/visual-plan/references/wireframe.md +0 -319
  84. package/skills/global_config/visual-recap/README.md +0 -103
  85. package/skills/global_config/visual-recap/SKILL.md +0 -560
  86. package/skills/global_config/visual-recap/references/connection.md +0 -51
  87. package/skills/global_config/visual-recap/references/local-files.md +0 -99
  88. package/skills/global_config/visual-recap/references/wireframe.md +0 -319
@@ -8,6 +8,7 @@ import path from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import os from 'node:os'
10
10
  import crypto from 'node:crypto'
11
+ import { norm, probePorts, mainRoot, gitRoots, listWorktrees, selectPlan, PROTOCOL } from './repoid.mjs'
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
13
14
 
@@ -15,7 +16,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
15
16
  const argv = process.argv.slice(2)
16
17
  let cmd = null
17
18
  let repo = process.cwd()
18
- let port = 5330
19
+ let port = probePorts()[0]
19
20
  const spawningPaths = new Set()
20
21
  let openBrowser = process.stdout.isTTY ? true : false
21
22
  let noOpen = false
@@ -64,8 +65,10 @@ for (let i = 0; i < argv.length; i++) {
64
65
  else if (cmd === 'ask') (askWords = askWords || []).push(a)
65
66
  else repo = path.resolve(a)
66
67
  }
68
+ // one map per git repo: any worktree resolves to the main checkout (init keeps the folder it was given)
69
+ if (!ensureMode && (cmd === null || cmd === 'autostart')) repo = mainRoot(repo)
67
70
  // AOS patch: --plan <path> overrides the default PLAN.md location
68
- const planPath = planArg ? path.resolve(repo, planArg) : path.join(repo, 'PLAN.md')
71
+ const planPath = planArg ? (p => fs.existsSync(p) ? norm(p) : p)(path.resolve(repo, planArg)) : path.join(repo, 'PLAN.md')
69
72
  const atDir = path.join(repo, '.agenttrail')
70
73
 
71
74
  function realDirectory(value) {
@@ -160,7 +163,7 @@ async function relayHook() {
160
163
  body = JSON.stringify(ev)
161
164
  } catch {}
162
165
  }
163
- const ports = process.env.AGENTTRAIL_PORT ? [parseInt(process.env.AGENTTRAIL_PORT, 10)] : Array.from({ length: 15 }, (_, i) => 5330 + i)
166
+ const ports = process.env.AGENTTRAIL_PORT ? [parseInt(process.env.AGENTTRAIL_PORT, 10)] : probePorts()
164
167
  await Promise.allSettled(ports.map(p => fetch(`http://127.0.0.1:${p}/hook`, {
165
168
  method: 'POST', body, signal: AbortSignal.timeout(400),
166
169
  }).catch(() => {})))
@@ -196,9 +199,8 @@ function parseTimeoutArg(v) {
196
199
  // repoPath — same discovery as bootDedup; AGENTTRAIL_PORT wins when set. Both sides are
197
200
  // symlink-normalized so a daemon started via /tmp matches a cwd of /private/tmp (macOS).
198
201
  async function findDaemon() {
199
- const norm = p => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } }
200
- const here = norm(repo)
201
- const ports = process.env.AGENTTRAIL_PORT ? [parseInt(process.env.AGENTTRAIL_PORT, 10)] : Array.from({ length: 15 }, (_, i) => 5330 + i)
202
+ const here = norm(mainRoot(repo))
203
+ const ports = process.env.AGENTTRAIL_PORT ? [parseInt(process.env.AGENTTRAIL_PORT, 10)] : probePorts()
202
204
  const hits = await Promise.all(ports.map(p =>
203
205
  fetch(`http://127.0.0.1:${p}/whoami`, { signal: AbortSignal.timeout(400) }).then(r => r.json()).then(w => (w && norm(w.repoPath) === here) ? p : null).catch(() => null)))
204
206
  return hits.find(Boolean) || null
@@ -294,13 +296,171 @@ function parsePlan(text) {
294
296
 
295
297
  // ---------- live state ----------
296
298
  const session = { id: Math.random().toString(36).slice(2, 10), project: path.basename(repo), startedAt: new Date().toISOString() }
297
- let planText = safeRead(planPath)
298
- let parsed = parsePlan(planText)
299
+ let planText = ''
300
+ let parsed = { nodes: [], decisions: [], title: '' }
299
301
  let activity = null // { file, at } — most recent non-plan repo write
300
302
  let recentActivity = [] // last N writes, newest first — feeds the live-view drill-down
301
- let planMtime = statMtime(planPath)
303
+ let planMtime = null
302
304
  const clients = new Set()
303
305
 
306
+ // ---------- lanes: the main checkout plus every linked worktree of the repo, one group each on the map ----------
307
+ const MAX_LANES = 12
308
+ const lanes = new Map() // key ('' = main checkout) -> { key, root, branch, label, watcher }
309
+ const laneList = () => [...lanes.values()]
310
+ const mainLane = () => lanes.get('')
311
+ const slug = s => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'wt'
312
+ function laneAt(abs) {
313
+ let best = null
314
+ for (const l of lanes.values()) if (isWithin(l.root, abs) && (!best || l.root.length > best.root.length)) best = l
315
+ return best
316
+ }
317
+ // plan file of a lane: the CLI path, mapped to the same repo-relative path in every worktree, else that worktree's own plan
318
+ function planRel() {
319
+ if (!planArg) return 'PLAN.md'
320
+ if (!path.isAbsolute(planArg)) return planArg
321
+ const l = laneAt(norm(planArg))
322
+ return l ? path.relative(l.root, norm(planArg)) : null
323
+ }
324
+ function lanePlan(l) {
325
+ const rel = planRel()
326
+ if (!l.key && !(planArg && path.isAbsolute(planArg) && rel !== null)) return planPath
327
+ if (rel === null) return selectPlan(l.root).plan || null
328
+ const f = path.join(l.root, rel)
329
+ return fs.existsSync(f) || !planArg ? f : selectPlan(l.root).plan || null
330
+ }
331
+ // rebuilds the merged plan: linked-worktree ids get a `<lane>--` prefix so one repo's plan can sit in many lanes
332
+ function reloadPlans() {
333
+ const nodes = []
334
+ const matchers = []
335
+ const texts = []
336
+ let title = ''
337
+ let decisions = []
338
+ let mtime = null
339
+ const multi = lanes.size > 1
340
+ for (const l of laneList()) {
341
+ const file = lanePlan(l)
342
+ const text = file ? safeRead(file) : ''
343
+ texts.push(text)
344
+ const p = parsePlan(text)
345
+ const pre = l.key ? l.key + '--' : ''
346
+ for (const n of p.nodes) {
347
+ if (pre) { n.id = pre + n.id; n.parent = n.parent && pre + n.parent; n.needs = n.needs.map(x => pre + x); n.links = n.links.map(x => pre + x) }
348
+ if (multi) { n.lane = l.key; n.laneLabel = l.label }
349
+ nodes.push(n)
350
+ if (n.level === 'component' && n.files.length) matchers.push({ id: n.id, lane: l.key, res: n.files.map(globToRe) })
351
+ }
352
+ if (!l.key) { title = p.title; decisions = p.decisions }
353
+ const m = file ? statMtime(file) : null
354
+ if (m && (!mtime || m > mtime)) mtime = m
355
+ }
356
+ planText = texts.filter(Boolean).join('\n')
357
+ parsed = { nodes, decisions, title }
358
+ compMatchers = matchers
359
+ planMtime = mtime
360
+ }
361
+ function watchLane(l) {
362
+ const onPlan = () => {
363
+ clearTimeout(planDebounce)
364
+ planDebounce = setTimeout(() => { reloadPlans(); broadcast() }, 150)
365
+ }
366
+ try {
367
+ l.watcher = fs.watch(l.root, { recursive: true }, (_ev, filename) => {
368
+ if (!filename) return
369
+ const f = filename.toString().split(path.sep).join('/')
370
+ if (IGNORE.test(f) || TMP_FILE.test(f)) return
371
+ const abs = path.resolve(l.root, f)
372
+ if (abs === lanePlan(l)) { onPlan(); return }
373
+ if (laneAt(abs) !== l) return // a nested worktree reports its own churn
374
+ const shown = l.key ? `${l.key}:${f}` : f
375
+ if (!l.key) treeDirty = true
376
+ stateDirty = true
377
+ activity = { file: shown, at: Date.now() }
378
+ heatFile(shown, activity.at)
379
+ touchComponents(f, activity.at, l.key)
380
+ if (!recentActivity.length || recentActivity[0].file !== shown) recentActivity.unshift(activity)
381
+ else recentActivity[0] = activity
382
+ recentActivity = recentActivity.slice(0, 12)
383
+ throttleBroadcast()
384
+ })
385
+ } catch (e) {
386
+ // recursive watch is unsupported on some platforms (older linux) — degrade:
387
+ // plan updates and top-level changes still tracked, deep activity limited.
388
+ try {
389
+ l.watcher = fs.watch(l.root, (_ev, filename) => {
390
+ if (filename && path.resolve(l.root, filename.toString()) === lanePlan(l)) onPlan()
391
+ })
392
+ if (!l.key) console.error('note: recursive file watching unavailable here — plan updates still live, deep file activity limited')
393
+ } catch (e2) {
394
+ console.error('watcher failed:', e2.message)
395
+ }
396
+ }
397
+ }
398
+ // re-reads `git worktree list`; returns true when the set of lanes changed
399
+ // main is always a lane; the other slots go to the most recently active worktrees, the rest share the 'other' lane
400
+ const OTHER = 'other'
401
+ const TTL = 30000
402
+ const active = new Map() // worktree root -> last event ts
403
+ const overCap = new Map() // worktree root without a lane -> cache expiry
404
+ const activeAt = p => { const t = active.get(p); if (t) return t; try { return fs.statSync(p).mtimeMs } catch { return 0 } }
405
+ const otherLane = root => ({ key: OTHER, root, label: 'other worktrees', branch: null, other: true })
406
+ const hasOther = () => Object.values(runs).some(r => r.lane === OTHER)
407
+ function refreshLanes() {
408
+ const all = listWorktrees(repo, Infinity)
409
+ const recent = p => Date.now() - (active.get(p) || 0) < 10 * 60e3 && laneList().some(l => l.root === p) // busy lanes keep their slot, so lanes do not thrash
410
+ const rank = p => recent(p) ? Infinity : activeAt(p)
411
+ const keep = new Set([all[0].path, ...all.slice(1).sort((a, b) => rank(b.path) - rank(a.path) || activeAt(b.path) - activeAt(a.path)).slice(0, MAX_LANES - 1).map(w => w.path)])
412
+ const wts = all.filter(w => keep.has(w.path))
413
+ const used = new Set(['', OTHER])
414
+ const next = new Map()
415
+ let changed = false
416
+ for (const w of wts) {
417
+ const old = laneList().find(l => l.root === w.path)
418
+ const main = w.path === repo
419
+ let key = main ? '' : old ? old.key : ''
420
+ if (!main && !old) { const base = slug(path.basename(w.path)); key = base; for (let i = 2; used.has(key) || (lanes.has(key) && lanes.get(key).root !== w.path); i++) key = `${base}-${i}` }
421
+ used.add(key)
422
+ const label = `${w.branch || 'no branch'} · ${path.basename(w.path)}`
423
+ const l = old || { key, root: w.path, watcher: null }
424
+ if (!old || l.branch !== w.branch) changed = true
425
+ l.branch = w.branch
426
+ l.label = label
427
+ next.set(key, l)
428
+ }
429
+ for (const [k, l] of lanes) if (!next.has(k)) { changed = true; try { l.watcher && l.watcher.close() } catch {} }
430
+ lanes.clear()
431
+ for (const [k, l] of next) lanes.set(k, l)
432
+ for (const l of laneList()) if (!l.watcher) watchLane(l)
433
+ for (const w of keep) overCap.delete(w)
434
+ if (changed) treeDirty = true
435
+ return changed
436
+ }
437
+ const missedCwd = new Map() // cwd -> expiry; keeps unrelated repos' events from costing a git call each
438
+ // the lane an event's cwd belongs to; git runs at most once per unknown cwd per TTL, over-cap worktrees are cached
439
+ function laneFor(cwd) {
440
+ if (!cwd) return null
441
+ const abs = path.resolve(cwd)
442
+ const now = Date.now()
443
+ let l = laneAt(abs) || laneAt(norm(abs))
444
+ if (l) { active.set(l.root, now); return l }
445
+ for (const [top, until] of overCap) if (until > now && isWithin(top, abs)) { active.set(top, now); return otherLane(top) }
446
+ const until = missedCwd.get(abs)
447
+ if (until && until > now) return null
448
+ const g = gitRoots(abs)
449
+ if (norm(g.main) === norm(repo)) {
450
+ const top = norm(g.top)
451
+ active.set(top, now)
452
+ if (refreshLanes()) { reloadPlans(); broadcast() }
453
+ l = laneAt(abs) || laneAt(norm(abs))
454
+ if (l) return l
455
+ if (top === norm(repo)) return mainLane()
456
+ overCap.set(top, now + TTL)
457
+ return otherLane(top)
458
+ }
459
+ if (missedCwd.size > 200) missedCwd.clear()
460
+ missedCwd.set(abs, now + TTL)
461
+ return null
462
+ }
463
+
304
464
  // ---------- observed activity per component (files: globs) ----------
305
465
  // Declared status ([~]/[x]) says what the agent claims; this says what the
306
466
  // filesystem shows. A component whose files are being written is live no
@@ -319,12 +479,8 @@ function globToRe(g) {
319
479
  return new RegExp('^' + esc + '$')
320
480
  }
321
481
  let compMatchers = []
322
- function rebuildMatchers() {
323
- compMatchers = parsed.nodes.filter(n => n.level === 'component' && n.files.length)
324
- .map(c => ({ id: c.id, res: c.files.map(globToRe) }))
325
- }
326
- function touchComponents(file, at) {
327
- for (const m of compMatchers) if (m.res.some(re => re.test(file))) {
482
+ function touchComponents(file, at, laneKey = '') {
483
+ for (const m of compMatchers) if (m.lane === laneKey && m.res.some(re => re.test(file))) {
328
484
  compTouched[m.id] = at
329
485
  const arr = compRecent[m.id] || (compRecent[m.id] = [])
330
486
  if (arr[0] && arr[0].file === file) arr[0] = { file, at, n: (arr[0].n || 1) + 1 }
@@ -342,15 +498,15 @@ const handoffs = [] // {c, from, to, at} — one session picks up where another
342
498
  function runFor(id, cwd) {
343
499
  return runs[id] || (runs[id] = { id, agent: 'claude', cwd, startedAt: Date.now(), lastEventAt: Date.now(), todos: [], currentTool: null, recentTools: [], componentId: null, ended: false, done: false, waiting: null })
344
500
  }
345
- function toolDetail(input = {}) {
501
+ function toolDetail(input = {}, root = repo) {
346
502
  const p = input.file_path || input.notebook_path
347
- const d = input.command || (p ? (relToRepo(p) ?? p) : '') || input.description || input.pattern || input.url || input.query || input.prompt || ''
503
+ const d = input.command || (p ? (relToRepo(p, root) ?? p) : '') || input.description || input.pattern || input.url || input.query || input.prompt || ''
348
504
  return String(d).replace(/\s+/g, ' ').slice(0, 90)
349
505
  }
350
- function relToRepo(p) {
506
+ function relToRepo(p, root = repo) {
351
507
  if (!p) return null
352
508
  const r = path.resolve(String(p))
353
- return r === repo ? '' : r.startsWith(repo + path.sep) ? r.slice(repo.length + 1) : null
509
+ return r === root ? '' : r.startsWith(root + path.sep) ? r.slice(root.length + 1) : null
354
510
  }
355
511
  // AOS patch: native plan/todo tools across harnesses, normalized to the
356
512
  // {content, status} shape the frontend's run-todo rendering already expects.
@@ -376,8 +532,10 @@ function normalizeTodos(toolName, input) {
376
532
  }
377
533
  function handleHookEvent(ev) {
378
534
  const cwd = ev.cwd || ''
379
- if (!(cwd === repo || cwd.startsWith(repo + path.sep))) return false
535
+ const lane = laneFor(cwd)
536
+ if (!lane) return false
380
537
  const run = runFor(ev.session_id || 'session', cwd)
538
+ run.lane = lane.key
381
539
  if (ev.agent) run.agent = String(ev.agent).slice(0, 24).toLowerCase()
382
540
  run.lastEventAt = Date.now()
383
541
  stateDirty = true
@@ -393,7 +551,7 @@ function handleHookEvent(ev) {
393
551
  run.ended = false
394
552
  run.done = false // AOS patch: an ended run that resumes is no longer done
395
553
  run.waiting = null // AOS patch: a resumed run is no longer waiting on the human
396
- run.currentTool = { name: ev.tool_name, detail: toolDetail(ev.tool_input), at: Date.now() }
554
+ run.currentTool = { name: ev.tool_name, detail: toolDetail(ev.tool_input, lane.root), at: Date.now() }
397
555
  if (ev.tool_name === 'Task' && ev.tool_input) {
398
556
  const name = String(ev.tool_input.description || ev.tool_input.subagent_type || 'sub-agent').slice(0, 60)
399
557
  ;(run.subagents = run.subagents || []).push({ name, startedAt: Date.now(), ended: false })
@@ -407,17 +565,18 @@ function handleHookEvent(ev) {
407
565
  run.done = false // AOS patch: keep done in sync when a run resumes
408
566
  run.waiting = null // AOS patch: a resumed run is no longer waiting on the human
409
567
  const started = run.currentTool && run.currentTool.name === ev.tool_name ? run.currentTool.at : Date.now()
410
- run.recentTools.unshift({ name: ev.tool_name, detail: toolDetail(ev.tool_input), at: Date.now(), ms: Date.now() - started })
568
+ run.recentTools.unshift({ name: ev.tool_name, detail: toolDetail(ev.tool_input, lane.root), at: Date.now(), ms: Date.now() - started })
411
569
  if (run.recentTools.length > 8) run.recentTools.length = 8
412
570
  run.currentTool = null
413
571
  const todos = normalizeTodos(ev.tool_name, ev.tool_input)
414
572
  if (todos) run.todos = todos
415
- const rel = relToRepo(ev.tool_input && (ev.tool_input.file_path || ev.tool_input.notebook_path))
573
+ const rel = relToRepo(ev.tool_input && (ev.tool_input.file_path || ev.tool_input.notebook_path), lane.root)
416
574
  if (rel) {
417
- heatFile(rel, Date.now())
418
- touchComponents(rel, Date.now())
419
- activity = { file: rel, at: Date.now() }
420
- for (const m of compMatchers) if (m.res.some(re => re.test(rel))) {
575
+ const shown = lane.key ? `${lane.key}:${rel}` : rel
576
+ heatFile(shown, Date.now())
577
+ touchComponents(rel, Date.now(), lane.key)
578
+ activity = { file: shown, at: Date.now() }
579
+ for (const m of compMatchers) if (m.lane === lane.key && m.res.some(re => re.test(rel))) {
421
580
  if (run.componentId !== m.id) {
422
581
  (run.path = run.path || []).push({ c: m.id, at: Date.now() })
423
582
  const prev = Object.values(runs).find(r2 => r2.id !== run.id && r2.ended && r2.componentId === m.id && Date.now() - r2.lastEventAt < 10 * 60e3)
@@ -520,6 +679,7 @@ function buildTree(rootDir, budgetN = 4000, perDir = 250) {
520
679
  for (const e of entries) {
521
680
  const r = rel ? rel + '/' + e.name : e.name
522
681
  if (IGNORE.test(r) || TMP_FILE.test(r)) continue
682
+ if (e.isDirectory() && laneList().some(l => l.key && l.root === path.join(dir, e.name))) continue
523
683
  if (taken >= perDir || budget <= 0) { treeTruncated = true; break }
524
684
  taken++; budget--
525
685
  if (e.isDirectory()) {
@@ -536,14 +696,13 @@ let treeTruncated = false
536
696
  let tree = buildTree(repo)
537
697
  let treeDirty = false
538
698
  let lastTreeSent = Date.now()
539
- rebuildMatchers()
540
699
 
541
700
  function safeRead(p) { try { return fs.readFileSync(p, 'utf8') } catch { return '' } }
542
701
  function statMtime(p) { try { return fs.statSync(p).mtimeMs } catch { return null } }
543
702
 
544
703
  function compFilesFor(id) {
545
704
  const m = compMatchers.find(x => x.id === id)
546
- if (!m) return []
705
+ if (!m || m.lane) return [] // only the main checkout has a file tree
547
706
  const out = []
548
707
  const walk = t => { for (const n of t) { if (n.dir) walk(n.children || []); else if (m.res.some(re => re.test(n.path))) out.push(n.path) } }
549
708
  walk(tree)
@@ -610,7 +769,7 @@ function syntheticPlanNodes() {
610
769
  function model() {
611
770
  if (treeDirty) { tree = buildTree(repo); treeDirty = false }
612
771
  // AOS patch: include planFile (relative path) and aosPlan (boolean) for AOS UI customization
613
- const planFile = planArg ? path.relative(repo, planPath).split(path.sep).join('/') : undefined
772
+ const planFile = planArg ? path.relative(repo, lanePlan(mainLane()) || planPath).split(path.sep).join('/') : undefined
614
773
  const aosPlan = Boolean(planArg)
615
774
  const realPlanNodes = parsed.nodes.map(n => n.level === 'component' ? { ...n, touchedAt: compTouched[n.id] || null, recent: compRecent[n.id] || [], filesList: compFilesFor(n.id) } : n)
616
775
  const planNodes = realPlanNodes.length ? realPlanNodes : syntheticPlanNodes()
@@ -620,6 +779,7 @@ function model() {
620
779
  session, plan: planNodes, tree,
621
780
  planTitle: parsed.title,
622
781
  hasPlan: planText.length > 0, treeTruncated,
782
+ lanes: lanes.size > 1 || hasOther() ? [...laneList(), ...(hasOther() ? [otherLane(null)] : [])].map(l => ({ key: l.key, label: l.label, branch: l.branch, root: l.root, main: !l.key, other: l.other || undefined })) : undefined,
623
783
  activity, recentActivity, planMtime, handoffs, asks, hotFiles: hotFiles(), cycles, // AOS patch: asks ride along so the open map updates live
624
784
  planStale: planStaleness(), lints: lintPlan(), hooksInstalled: hooksInstalled(),
625
785
  planFile, aosPlan,
@@ -639,56 +799,9 @@ function broadcastTick() {
639
799
 
640
800
  // ---------- watcher ----------
641
801
  let planDebounce = null
642
- try {
643
- fs.watch(repo, { recursive: true }, (_ev, filename) => {
644
- if (!filename) return
645
- const f = filename.toString().split(path.sep).join('/')
646
- if (IGNORE.test(f) || TMP_FILE.test(f)) return
647
- if (path.resolve(repo, f) === planPath) {
648
- clearTimeout(planDebounce)
649
- planDebounce = setTimeout(() => {
650
- planText = safeRead(planPath)
651
- parsed = parsePlan(planText)
652
- rebuildMatchers()
653
- planMtime = statMtime(planPath)
654
- broadcast()
655
- }, 150)
656
- return
657
- }
658
- // plain repo churn → liveness signal
659
- treeDirty = true
660
- stateDirty = true
661
- activity = { file: f, at: Date.now() }
662
- heatFile(f, activity.at)
663
- touchComponents(f, activity.at)
664
- if (!recentActivity.length || recentActivity[0].file !== f) recentActivity.unshift(activity)
665
- else recentActivity[0] = activity
666
- recentActivity = recentActivity.slice(0, 12)
667
- throttleBroadcast()
668
- })
669
- } catch (e) {
670
- // recursive watch is unsupported on some platforms (older linux) — degrade:
671
- // PLAN.md and top-level changes still tracked, deep activity limited.
672
- try {
673
- fs.watch(repo, (_ev, filename) => {
674
- if (!filename) return
675
- const f = filename.toString()
676
- if (path.resolve(repo, f) === planPath) {
677
- clearTimeout(planDebounce)
678
- planDebounce = setTimeout(() => {
679
- planText = safeRead(planPath)
680
- parsed = parsePlan(planText)
681
- rebuildMatchers()
682
- planMtime = statMtime(planPath)
683
- broadcast()
684
- }, 150)
685
- }
686
- })
687
- console.error('note: recursive file watching unavailable here — plan updates still live, deep file activity limited')
688
- } catch (e2) {
689
- console.error('watcher failed:', e2.message)
690
- }
691
- }
802
+ refreshLanes()
803
+ reloadPlans()
804
+ setInterval(() => { if (refreshLanes()) { reloadPlans(); broadcast() } }, 30000).unref()
692
805
  let lastActivityPush = 0
693
806
  function throttleBroadcast() {
694
807
  const now = Date.now()
@@ -702,7 +815,7 @@ function throttleBroadcast() {
702
815
  let boards = [{ port, project: path.basename(repo), self: true, repoPath: repo }]
703
816
  async function discoverBoards() {
704
817
  const found = [{ port, project: session.project, self: true, repoPath: repo }]
705
- await Promise.allSettled(Array.from({ length: 15 }, (_, i) => 5330 + i).filter(p => p !== port).map(async p => {
818
+ await Promise.allSettled(probePorts().filter(p => p !== port).map(async p => {
706
819
  try {
707
820
  const r = await fetch(`http://127.0.0.1:${p}/whoami`, { signal: AbortSignal.timeout(300) })
708
821
  const j = await r.json()
@@ -770,20 +883,20 @@ const server = http.createServer((req, res) => {
770
883
  // AOS patch: serve pipeline artifacts so a card's relative `url:` (e.g. an archify diagram) opens
771
884
  let root
772
885
  let f
773
- try {
774
- root = fs.realpathSync(path.join(repo, 'production_artifacts'))
775
- f = fs.realpathSync(path.resolve(repo, '.' + decodeURIComponent(u.pathname)))
776
- } catch {
777
- res.writeHead(404).end()
778
- return
779
- }
780
886
  let isFile = false
781
- try { isFile = fs.statSync(f).isFile() } catch {}
782
- if (!isWithin(root, f) || !isFile) { res.writeHead(404).end(); return }
887
+ for (const l of laneList()) {
888
+ try {
889
+ root = fs.realpathSync(path.join(l.root, 'production_artifacts'))
890
+ f = fs.realpathSync(path.resolve(l.root, '.' + decodeURIComponent(u.pathname)))
891
+ isFile = fs.statSync(f).isFile() && isWithin(root, f)
892
+ } catch { isFile = false }
893
+ if (isFile) break
894
+ }
895
+ if (!isFile) { res.writeHead(404).end(); return }
783
896
  const type = f.endsWith('.html') ? 'text/html' : f.endsWith('.svg') ? 'image/svg+xml' : f.endsWith('.json') ? 'application/json' : 'text/plain'
784
897
  res.writeHead(200, { 'content-type': type + '; charset=utf-8' }).end(fs.readFileSync(f))
785
898
  } else if (u.pathname === '/whoami') {
786
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ project: session.project, port, repoPath: repo }))
899
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ protocol: PROTOCOL, project: session.project, port, repoPath: repo, worktrees: laneList().map(l => ({ path: l.root, branch: l.branch, main: !l.key })) }))
787
900
  } else if (u.pathname === '/board-lite') {
788
901
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(liteModel()))
789
902
  } else if (u.pathname === '/world') {
@@ -810,18 +923,38 @@ const server = http.createServer((req, res) => {
810
923
  } catch {}
811
924
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out.slice(0, 15)))
812
925
  } else if (u.pathname === '/setup' && req.method === 'POST') {
813
- init().then(() => {
814
- planText = safeRead(planPath); parsed = parsePlan(planText); rebuildMatchers(); planMtime = statMtime(planPath)
815
- broadcast()
816
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, prompt: BACKFILL_PROMPT }))
817
- }).catch(e => res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: String(e) })))
926
+ readCapped(req, res, body => {
927
+ const reply = o => res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(o))
928
+ let key = u.searchParams.get('lane')
929
+ try { const j = JSON.parse(body || '{}'); if (typeof j.lane === 'string') key = j.lane } catch {}
930
+ const lane = key === null ? (lanes.size > 1 ? null : mainLane()) : lanes.get(key)
931
+ if (!lane) { reply({ ok: false, error: key === null ? `this map has several lanes, so setup needs {"lane": "<key>"} (${laneList().map(l => l.key || '""=main').join(', ')}) to write into the right checkout` : 'unknown lane' }); return }
932
+ init(lane.root, lanePlan(lane) || path.join(lane.root, 'PLAN.md')).then(() => {
933
+ reloadPlans()
934
+ broadcast()
935
+ reply({ ok: true, prompt: BACKFILL_PROMPT })
936
+ }).catch(e => reply({ ok: false, error: String(e) }))
937
+ }, MAX_CONTROL_BODY_BYTES)
938
+ } else if (u.pathname === '/shutdown' && req.method === 'POST') {
939
+ // same CSRF guard as /answer, plus the caller must name this daemon's repo
940
+ const ct = req.headers['content-type']
941
+ const origin = req.headers['origin']
942
+ const ownOrigin = origin === `http://localhost:${port}` || origin === `http://127.0.0.1:${port}`
943
+ if (ct !== 'application/json' || (origin !== undefined && !ownOrigin)) { res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'forbidden' })); return }
944
+ readCapped(req, res, body => {
945
+ let named = null
946
+ try { named = JSON.parse(body || '{}').repoPath } catch {}
947
+ if (typeof named !== 'string' || norm(named) !== norm(repo)) { res.writeHead(409, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'repo mismatch' })); return }
948
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }), () => { stateDirty = true; saveState(); process.exit(0) })
949
+ }, MAX_CONTROL_BODY_BYTES)
818
950
  } else if (u.pathname === '/setup-board' && req.method === 'POST') {
819
951
  readCapped(req, res, async body => {
820
952
  let out = { ok: false, error: 'no board there' }
821
953
  try {
822
- const p = Number(JSON.parse(body || '{}').port)
954
+ const j = JSON.parse(body || '{}')
955
+ const p = Number(j.port)
823
956
  if (!Number.isInteger(p) || p < 1 || p > 65535) out = { ok: false, error: 'bad port' }
824
- else out = await fetch(`http://127.0.0.1:${p}/setup`, { method: 'POST', signal: AbortSignal.timeout(5000) }).then(r => r.json())
957
+ else out = await fetch(`http://127.0.0.1:${p}/setup`, { method: 'POST', body: JSON.stringify(typeof j.lane === 'string' ? { lane: j.lane } : {}), signal: AbortSignal.timeout(5000) }).then(r => r.json())
825
958
  } catch {}
826
959
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out))
827
960
  }, MAX_CONTROL_BODY_BYTES)
@@ -833,16 +966,17 @@ const server = http.createServer((req, res) => {
833
966
  if (!p) out = { ok: false, error: 'spawn target is outside the allowed workspace' }
834
967
  else {
835
968
  let already = null
969
+ const target = mainRoot(p)
836
970
  for (const b of boards) {
837
971
  try {
838
972
  const w = await fetch(`http://127.0.0.1:${b.port}/whoami`, { signal: AbortSignal.timeout(300) }).then(r => r.json())
839
- if (w.repoPath && realDirectory(w.repoPath) === p) { already = b.port; break }
973
+ if (w.repoPath && realDirectory(w.repoPath) === target) { already = b.port; break }
840
974
  } catch {}
841
975
  }
842
976
  if (already) out = { ok: true, already }
843
- else if (spawningPaths.has(p)) out = { ok: true }
977
+ else if (spawningPaths.has(target)) out = { ok: true }
844
978
  else {
845
- spawningPaths.add(p); setTimeout(() => spawningPaths.delete(p), 30000)
979
+ spawningPaths.add(target); setTimeout(() => spawningPaths.delete(target), 30000)
846
980
  const cp = await import('node:child_process')
847
981
  const child = cp.spawn(process.execPath, [fileURLToPath(import.meta.url), p, '--no-open'], { detached: true, stdio: 'ignore', shell: false })
848
982
  child.unref()
@@ -858,8 +992,7 @@ const server = http.createServer((req, res) => {
858
992
  const cwd = u.searchParams.get('cwd') || ''
859
993
  const st = planStaleness()
860
994
  const candidate = realDirectory(cwd)
861
- const root = realDirectory(repo)
862
- const mine = !!(candidate && root && isWithin(root, candidate))
995
+ const mine = !!(candidate && laneAt(candidate))
863
996
  res.writeHead(200, { 'content-type': 'text/plain' }).end(mine && st.stale
864
997
  ? `Note from agenttrail: PLAN.md in this repo is ${st.minutes} minutes behind the code. Re-verify task statuses against what you and other sessions actually changed, mark your current task [~], and keep files: globs current.`
865
998
  : '')
@@ -987,7 +1120,7 @@ async function firstRunFlow() {
987
1120
  console.log('\nno PLAN.md here yet — the live layer works now; the map needs a plan.')
988
1121
  if (await askYesNo('set up the plan convention (PLAN.md skeleton + agent instructions + local hooks)? [Y/n] ')) {
989
1122
  await init()
990
- planText = safeRead(planPath); parsed = parsePlan(planText); rebuildMatchers(); planMtime = statMtime(planPath)
1123
+ reloadPlans()
991
1124
  const copied = await copyToClipboard(BACKFILL_PROMPT)
992
1125
  console.log(copied
993
1126
  ? 'backfill prompt is on your clipboard — paste it to your agent in this repo and the map draws itself.'
@@ -1014,8 +1147,8 @@ function onListen() {
1014
1147
  }
1015
1148
  async function bootDedup() {
1016
1149
  const probes = []
1017
- for (let p = 5330; p < 5345; p++) probes.push(
1018
- fetch(`http://127.0.0.1:${p}/whoami`, { signal: AbortSignal.timeout(400) }).then(r => r.json()).then(w => w.repoPath === repo ? p : null).catch(() => null))
1150
+ for (const p of probePorts()) probes.push(
1151
+ fetch(`http://127.0.0.1:${p}/whoami`, { signal: AbortSignal.timeout(400) }).then(r => r.json()).then(w => norm(w.repoPath) === norm(repo) && w.protocol >= PROTOCOL ? p : null).catch(() => null))
1019
1152
  const hit = (await Promise.all(probes)).find(Boolean)
1020
1153
  if (hit) { console.log(`agenttrail is already running for this repo · http://localhost:${hit}`); process.exit(0) }
1021
1154
  listenWithFallback()
@@ -1023,10 +1156,10 @@ async function bootDedup() {
1023
1156
  bootDedup()
1024
1157
 
1025
1158
  // ---------- init ----------
1026
- async function init() {
1027
- fs.mkdirSync(atDir, { recursive: true })
1028
- if (!fs.existsSync(planPath)) {
1029
- fs.writeFileSync(planPath, `# ${path.basename(repo)}
1159
+ async function init(root = repo, plan = planPath) {
1160
+ fs.mkdirSync(path.join(root, '.agenttrail'), { recursive: true })
1161
+ if (!fs.existsSync(plan)) {
1162
+ fs.writeFileSync(plan, `# ${path.basename(root)}
1030
1163
 
1031
1164
  ## Set up the project {#setup}
1032
1165
  tech: scaffolding
@@ -1041,25 +1174,25 @@ tech: scaffolding
1041
1174
  // CLAUDE.md is read by Claude Code, AGENTS.md by Codex/Cursor and friends —
1042
1175
  // the convention block goes in both so any agent maintains the same plan.
1043
1176
  for (const name of ['CLAUDE.md', 'AGENTS.md']) {
1044
- const p = path.join(repo, name)
1177
+ const p = path.join(root, name)
1045
1178
  if (!safeRead(p).includes(marker)) {
1046
1179
  fs.appendFileSync(p, snippet)
1047
1180
  console.log(`appended agenttrail convention block to ${name}`)
1048
1181
  }
1049
1182
  }
1050
- if (await askYesNo('wire Claude Code hooks into .claude/settings.local.json (gitignored, local only)? [Y/n] ')) installHooks()
1051
- const gi = path.join(repo, '.gitignore')
1183
+ if (await askYesNo('wire Claude Code hooks into .claude/settings.local.json (gitignored, local only)? [Y/n] ')) installHooks(root)
1184
+ const gi = path.join(root, '.gitignore')
1052
1185
  const giText = safeRead(gi)
1053
1186
  if (!giText.includes('.agenttrail')) fs.appendFileSync(gi, (giText.endsWith('\n') || !giText ? '' : '\n') + '.agenttrail/\n')
1054
- console.log('done — start the daemon with: agenttrail ' + repo)
1187
+ console.log('done — start the daemon with: agenttrail ' + root)
1055
1188
  console.log('\nnext: have your agent draw the real map. Paste this to Claude Code or Codex in this repo:\n')
1056
1189
  console.log(' Read the "agenttrail plan convention" section in CLAUDE.md or AGENTS.md. Then study this repo in trust order — the code and directory layout first (what exists), git log for what is actually recent, decision logs and any in-progress build/handoff docs for what is in flight, and README/roadmap prose LAST and only for open intent (founding docs rot; cross-check their claims against git log) — and rewrite PLAN.md as the real map of this codebase: components with stable {#id}s, needs:/links: edges between them, files: globs for the paths each owns, and verb-led concrete titles with tech: sublines. Keep it to 5-9 components no matter how big the repo — a component is a part one agent could own for a session, with its own doneness and at least one edge; anything smaller is a task inside one. Verify every status against the CODE, not the docs — READMEs and roadmaps rot; mark [x] only after finding the implementing source or artifact, and cite that evidence on its tech: line (e.g. tech: TurnView.swift) so a human can audit every tick; if you cannot find evidence, leave it unchecked and say so in the decisions note. Statuses must be honest — [x] only for what verifiably exists, [~] only for what you are working on right now, everything else [ ]. Tag open tasks with an indented from: line naming provenance — from: agent when an in-progress build/handoff doc or your own declared intent claims it imminently, from: roadmap when sourced only from planning docs; omit when unsure. Record the backfill under ## decisions.')
1057
1190
  }
1058
1191
 
1059
1192
  // merge our hook relay into the repo's .claude/settings.json so Claude Code
1060
1193
  // sessions stream tool calls + todos to the board. Additive and idempotent.
1061
- function installHooks() {
1062
- const dir = path.join(repo, '.claude')
1194
+ function installHooks(root = repo) {
1195
+ const dir = path.join(root, '.claude')
1063
1196
  const sp = path.join(dir, 'settings.local.json')
1064
1197
  let cfg = {}
1065
1198
  try { cfg = JSON.parse(fs.readFileSync(sp, 'utf8')) } catch {}
@@ -1084,10 +1217,15 @@ async function upAll() {
1084
1217
  let entries = []
1085
1218
  try { entries = fs.readdirSync(dir).filter(f => f.endsWith('.json')) } catch {}
1086
1219
  const known = []
1220
+ const seen = new Set()
1087
1221
  for (const f of entries) {
1088
1222
  try {
1089
1223
  const st = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))
1090
- if (st.repoPath && fs.existsSync(st.repoPath)) known.push({ repo: st.repoPath, port: st.port || 5330 })
1224
+ if (!st.repoPath || !fs.existsSync(st.repoPath)) continue
1225
+ const main = mainRoot(st.repoPath) // state files of old per-worktree maps collapse into their repo's one map
1226
+ if (seen.has(main)) continue
1227
+ seen.add(main)
1228
+ known.push({ repo: main, port: st.port || 5330 })
1091
1229
  } catch {}
1092
1230
  }
1093
1231
  if (!known.length) { console.log('no known repos yet — run agenttrail in a repo first'); return }