@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.0

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 (285) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli.js +4659 -4261
  4. package/dist/types/async/job-manager.d.ts +37 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/bench-cli.d.ts +41 -0
  7. package/dist/types/commands/bench.d.ts +15 -3
  8. package/dist/types/commands/launch.d.ts +4 -0
  9. package/dist/types/config/model-registry.d.ts +3 -1
  10. package/dist/types/config/models-config-schema.d.ts +18 -6
  11. package/dist/types/config/models-config.d.ts +14 -5
  12. package/dist/types/config/provider-globals.d.ts +2 -2
  13. package/dist/types/config/service-tier.d.ts +5 -1
  14. package/dist/types/config/settings-schema.d.ts +316 -47
  15. package/dist/types/config/settings.d.ts +7 -0
  16. package/dist/types/dap/client.d.ts +20 -0
  17. package/dist/types/exec/bash-executor.d.ts +38 -0
  18. package/dist/types/exec/direnv.d.ts +33 -0
  19. package/dist/types/export/html/args.d.ts +15 -0
  20. package/dist/types/export/html/index.d.ts +11 -26
  21. package/dist/types/export/html/web-palette.d.ts +55 -116
  22. package/dist/types/extensibility/extensions/loader.d.ts +3 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +17 -2
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
  25. package/dist/types/live/controller.d.ts +40 -0
  26. package/dist/types/live/protocol.d.ts +85 -0
  27. package/dist/types/live/protocol.test.d.ts +1 -0
  28. package/dist/types/live/transport.d.ts +48 -0
  29. package/dist/types/live/visualizer.d.ts +33 -0
  30. package/dist/types/lsp/client.d.ts +16 -1
  31. package/dist/types/mcp/config-writer.test.d.ts +1 -0
  32. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  33. package/dist/types/mcp/smithery-auth.d.ts +1 -1
  34. package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
  35. package/dist/types/memory-backend/tool-names.d.ts +2 -0
  36. package/dist/types/modes/components/login-dialog.d.ts +2 -2
  37. package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
  38. package/dist/types/modes/components/settings-defs.d.ts +7 -1
  39. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
  40. package/dist/types/modes/components/tool-execution.d.ts +9 -1
  41. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/event-controller.d.ts +6 -1
  43. package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
  44. package/dist/types/modes/interactive-mode.d.ts +5 -0
  45. package/dist/types/modes/theme/theme.d.ts +1 -1
  46. package/dist/types/modes/types.d.ts +3 -1
  47. package/dist/types/modes/utils/context-usage.d.ts +12 -2
  48. package/dist/types/plan-mode/plan-files.d.ts +10 -0
  49. package/dist/types/registry/agent-lifecycle.d.ts +21 -9
  50. package/dist/types/registry/agent-registry.d.ts +11 -4
  51. package/dist/types/sdk.d.ts +15 -1
  52. package/dist/types/secrets/index.d.ts +15 -2
  53. package/dist/types/secrets/obfuscator.d.ts +113 -15
  54. package/dist/types/session/acp-permission-gate.d.ts +19 -0
  55. package/dist/types/session/agent-session-events.d.ts +84 -0
  56. package/dist/types/session/agent-session-types.d.ts +314 -0
  57. package/dist/types/session/agent-session.d.ts +118 -755
  58. package/dist/types/session/async-job-delivery.d.ts +28 -0
  59. package/dist/types/session/bash-runner.d.ts +75 -0
  60. package/dist/types/session/blob-store.d.ts +11 -1
  61. package/dist/types/session/blob-store.test.d.ts +1 -0
  62. package/dist/types/session/checkpoint-entries.d.ts +28 -0
  63. package/dist/types/session/eval-runner.d.ts +49 -0
  64. package/dist/types/session/irc-bridge.d.ts +46 -0
  65. package/dist/types/session/messages.d.ts +22 -0
  66. package/dist/types/session/model-controls.d.ts +180 -0
  67. package/dist/types/session/prewalk.d.ts +46 -0
  68. package/dist/types/session/queued-messages.d.ts +22 -0
  69. package/dist/types/session/redis-session-storage.d.ts +1 -0
  70. package/dist/types/session/retry-fallback-chains.d.ts +74 -0
  71. package/dist/types/session/role-models.d.ts +15 -0
  72. package/dist/types/session/session-advisors.d.ts +228 -0
  73. package/dist/types/session/session-entries.d.ts +8 -0
  74. package/dist/types/session/session-handoff.d.ts +70 -0
  75. package/dist/types/session/session-history-format.d.ts +6 -1
  76. package/dist/types/session/session-maintenance.d.ts +246 -0
  77. package/dist/types/session/session-manager.d.ts +41 -0
  78. package/dist/types/session/session-memory.d.ts +56 -0
  79. package/dist/types/session/session-provider-boundary.d.ts +62 -0
  80. package/dist/types/session/session-stats.d.ts +48 -0
  81. package/dist/types/session/session-tools.d.ts +143 -0
  82. package/dist/types/session/session-workspace.d.ts +32 -0
  83. package/dist/types/session/stream-guards.d.ts +44 -0
  84. package/dist/types/session/todo-tracker.d.ts +56 -0
  85. package/dist/types/session/ttsr-coordinator.d.ts +51 -0
  86. package/dist/types/session/turn-recovery.d.ts +225 -0
  87. package/dist/types/system-prompt.d.ts +2 -0
  88. package/dist/types/task/executor.d.ts +4 -1
  89. package/dist/types/task/output-manager.d.ts +3 -1
  90. package/dist/types/tools/approval.d.ts +1 -0
  91. package/dist/types/tools/browser/launch.d.ts +5 -0
  92. package/dist/types/tools/image-gen.d.ts +4 -4
  93. package/dist/types/tools/image-providers.d.ts +38 -0
  94. package/dist/types/tools/index.d.ts +5 -0
  95. package/dist/types/tools/path-utils.d.ts +8 -0
  96. package/dist/types/tools/todo.d.ts +10 -4
  97. package/dist/types/tools/xdev.d.ts +5 -1
  98. package/dist/types/tts/streaming-player.d.ts +14 -0
  99. package/dist/types/tts/vocalizer.d.ts +5 -0
  100. package/dist/types/utils/jj.d.ts +29 -0
  101. package/dist/types/utils/lang-from-path.d.ts +1 -0
  102. package/dist/types/utils/title-generator.d.ts +30 -0
  103. package/dist/types/vibe/runtime.d.ts +36 -8
  104. package/dist/types/web/search/index.d.ts +1 -1
  105. package/dist/types/web/search/provider.d.ts +15 -5
  106. package/dist/types/web/search/types.d.ts +94 -0
  107. package/package.json +12 -12
  108. package/scripts/generate-share-viewer.ts +4 -6
  109. package/src/advisor/__tests__/advisor.test.ts +643 -0
  110. package/src/advisor/runtime.ts +144 -43
  111. package/src/async/job-manager.ts +90 -2
  112. package/src/cli/args.ts +2 -0
  113. package/src/cli/auth-gateway-cli.ts +18 -3
  114. package/src/cli/bench-cli.ts +395 -29
  115. package/src/cli/flag-tables.ts +3 -0
  116. package/src/cli/gc-cli.ts +2 -2
  117. package/src/cli/usage-cli.ts +10 -1
  118. package/src/collab/host.ts +3 -2
  119. package/src/commands/bench.ts +18 -4
  120. package/src/commands/launch.ts +4 -0
  121. package/src/config/model-registry.ts +10 -2
  122. package/src/config/models-config-schema.ts +15 -4
  123. package/src/config/provider-globals.ts +9 -9
  124. package/src/config/service-tier.ts +26 -1
  125. package/src/config/settings-schema.ts +209 -51
  126. package/src/config/settings.ts +81 -2
  127. package/src/dap/client.ts +37 -4
  128. package/src/edit/index.ts +49 -12
  129. package/src/eval/py/executor.ts +103 -33
  130. package/src/exec/bash-executor.ts +89 -6
  131. package/src/exec/direnv.ts +145 -0
  132. package/src/export/html/args.ts +20 -0
  133. package/src/export/html/index.ts +45 -47
  134. package/src/export/html/template.css +19 -1
  135. package/src/export/html/template.html +6 -0
  136. package/src/export/html/template.js +21 -0
  137. package/src/export/html/tool-views.generated.js +31 -31
  138. package/src/export/html/web-palette.ts +116 -132
  139. package/src/export/share.ts +249 -49
  140. package/src/extensibility/extensions/loader.ts +29 -1
  141. package/src/extensibility/extensions/runner.ts +6 -0
  142. package/src/extensibility/extensions/types.ts +33 -2
  143. package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
  144. package/src/hindsight/state.ts +64 -6
  145. package/src/internal-urls/local-protocol.ts +2 -1
  146. package/src/internal-urls/memory-protocol.ts +2 -2
  147. package/src/internal-urls/skill-protocol.ts +2 -2
  148. package/src/internal-urls/ssh-protocol.ts +2 -1
  149. package/src/internal-urls/vault-protocol.ts +2 -1
  150. package/src/live/audio-worklet.txt +59 -0
  151. package/src/live/browser-runtime.txt +221 -0
  152. package/src/live/controller.ts +464 -0
  153. package/src/live/prompts/agent-final-message.md +3 -0
  154. package/src/live/prompts/live-instructions.md +23 -0
  155. package/src/live/protocol.test.ts +140 -0
  156. package/src/live/protocol.ts +233 -0
  157. package/src/live/transport.ts +490 -0
  158. package/src/live/visualizer.ts +234 -0
  159. package/src/lsp/client.ts +29 -9
  160. package/src/lsp/index.ts +19 -4
  161. package/src/main.ts +8 -1
  162. package/src/mcp/config-writer.test.ts +43 -0
  163. package/src/mcp/config-writer.ts +109 -82
  164. package/src/mcp/oauth-discovery.ts +10 -2
  165. package/src/mcp/smithery-auth.test.ts +29 -0
  166. package/src/mcp/smithery-auth.ts +3 -2
  167. package/src/memories/index.ts +40 -20
  168. package/src/memory-backend/tool-names.ts +2 -0
  169. package/src/mnemopi/backend.ts +24 -7
  170. package/src/modes/acp/acp-agent.ts +56 -10
  171. package/src/modes/acp/acp-event-mapper.ts +8 -1
  172. package/src/modes/components/agent-hub.ts +1 -1
  173. package/src/modes/components/login-dialog.ts +14 -4
  174. package/src/modes/components/plan-review-overlay.ts +350 -35
  175. package/src/modes/components/settings-defs.ts +23 -1
  176. package/src/modes/components/settings-selector.ts +169 -1
  177. package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
  178. package/src/modes/components/status-line/component.ts +123 -2
  179. package/src/modes/components/tool-execution.ts +11 -2
  180. package/src/modes/controllers/command-controller.ts +56 -15
  181. package/src/modes/controllers/event-controller.ts +111 -16
  182. package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
  183. package/src/modes/controllers/extension-ui-controller.ts +20 -4
  184. package/src/modes/controllers/live-command-controller.ts +178 -0
  185. package/src/modes/controllers/mcp-command-controller.ts +10 -1
  186. package/src/modes/controllers/omfg-controller.ts +44 -40
  187. package/src/modes/controllers/selector-controller.ts +18 -10
  188. package/src/modes/controllers/session-focus-controller.ts +6 -1
  189. package/src/modes/interactive-mode.ts +157 -38
  190. package/src/modes/runtime-init.ts +2 -0
  191. package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
  192. package/src/modes/theme/theme.ts +1 -1
  193. package/src/modes/types.ts +3 -1
  194. package/src/modes/utils/context-usage.ts +23 -7
  195. package/src/modes/utils/ui-helpers.ts +10 -5
  196. package/src/plan-mode/plan-files.ts +40 -0
  197. package/src/prompts/bench/cache-prefix-chunk.md +1 -0
  198. package/src/prompts/bench/cache-prefix.md +3 -0
  199. package/src/prompts/bench/cache-suffix.md +1 -0
  200. package/src/prompts/system/custom-system-prompt.md +1 -1
  201. package/src/prompts/system/project-prompt.md +8 -1
  202. package/src/prompts/system/subagent-async-pending.md +6 -0
  203. package/src/prompts/system/system-prompt.md +1 -1
  204. package/src/prompts/system/workflow-notice.md +48 -40
  205. package/src/prompts/system/xdev-mount-notice.md +4 -0
  206. package/src/prompts/tools/todo.md +3 -1
  207. package/src/registry/agent-lifecycle.ts +116 -46
  208. package/src/registry/agent-registry.ts +35 -8
  209. package/src/registry/persisted-agents.ts +26 -2
  210. package/src/sdk.ts +210 -136
  211. package/src/secrets/index.ts +127 -4
  212. package/src/secrets/obfuscator.ts +2328 -105
  213. package/src/session/acp-permission-gate.ts +165 -0
  214. package/src/session/agent-session-error-log.test.ts +1 -1
  215. package/src/session/agent-session-events.ts +66 -0
  216. package/src/session/agent-session-types.ts +334 -0
  217. package/src/session/agent-session.ts +1892 -12226
  218. package/src/session/artifacts.ts +6 -5
  219. package/src/session/async-job-delivery.ts +74 -0
  220. package/src/session/bash-runner.ts +326 -0
  221. package/src/session/blob-store.test.ts +56 -0
  222. package/src/session/blob-store.ts +18 -2
  223. package/src/session/checkpoint-entries.ts +81 -0
  224. package/src/session/eval-runner.ts +212 -0
  225. package/src/session/indexed-session-storage.ts +9 -2
  226. package/src/session/irc-bridge.ts +203 -0
  227. package/src/session/messages.ts +263 -1
  228. package/src/session/model-controls.ts +714 -0
  229. package/src/session/prewalk.ts +252 -0
  230. package/src/session/queued-messages.ts +93 -0
  231. package/src/session/redis-session-storage.ts +51 -11
  232. package/src/session/retry-fallback-chains.ts +455 -0
  233. package/src/session/role-models.ts +85 -0
  234. package/src/session/session-advisors.ts +1679 -0
  235. package/src/session/session-context.ts +6 -3
  236. package/src/session/session-entries.ts +8 -0
  237. package/src/session/session-handoff.ts +308 -0
  238. package/src/session/session-history-format.ts +20 -9
  239. package/src/session/session-loader.ts +0 -46
  240. package/src/session/session-maintenance.ts +2983 -0
  241. package/src/session/session-manager.ts +390 -30
  242. package/src/session/session-memory.ts +222 -0
  243. package/src/session/session-provider-boundary.ts +307 -0
  244. package/src/session/session-stats.ts +293 -0
  245. package/src/session/session-tools.ts +943 -0
  246. package/src/session/session-workspace.ts +53 -0
  247. package/src/session/stream-guards.ts +417 -0
  248. package/src/session/todo-tracker.ts +380 -0
  249. package/src/session/ttsr-coordinator.ts +496 -0
  250. package/src/session/turn-recovery.ts +1629 -0
  251. package/src/slash-commands/builtin-registry.ts +92 -11
  252. package/src/slash-commands/helpers/usage-report.ts +22 -3
  253. package/src/system-prompt.ts +41 -17
  254. package/src/task/executor.ts +285 -70
  255. package/src/task/index.ts +98 -105
  256. package/src/task/output-manager.ts +25 -3
  257. package/src/task/persisted-revive.ts +4 -3
  258. package/src/task/structured-subagent.ts +1 -0
  259. package/src/tools/approval.ts +55 -10
  260. package/src/tools/bash-interactive.ts +26 -0
  261. package/src/tools/bash-skill-urls.ts +28 -2
  262. package/src/tools/bash.ts +339 -123
  263. package/src/tools/browser/launch.ts +9 -1
  264. package/src/tools/browser/tab-supervisor.ts +8 -5
  265. package/src/tools/browser.ts +94 -47
  266. package/src/tools/image-gen.ts +20 -28
  267. package/src/tools/image-providers.ts +50 -0
  268. package/src/tools/index.ts +9 -0
  269. package/src/tools/path-utils.ts +49 -2
  270. package/src/tools/read.ts +156 -88
  271. package/src/tools/renderers.ts +7 -1
  272. package/src/tools/todo.ts +101 -11
  273. package/src/tools/vibe.ts +1 -2
  274. package/src/tools/write.ts +51 -1
  275. package/src/tools/xdev.ts +67 -3
  276. package/src/tts/streaming-player.ts +62 -4
  277. package/src/tts/vocalizer.ts +18 -1
  278. package/src/utils/jj.ts +125 -4
  279. package/src/utils/lang-from-path.ts +7 -0
  280. package/src/utils/title-generator.ts +137 -1
  281. package/src/vibe/runtime.ts +852 -72
  282. package/src/web/search/index.ts +3 -6
  283. package/src/web/search/provider.ts +31 -16
  284. package/src/web/search/providers/xai.ts +7 -1
  285. package/src/web/search/types.ts +3 -0
@@ -24,7 +24,9 @@ import { registerArtifactsDir } from "../internal-urls/registry-helpers";
24
24
  import { MCPManager } from "../mcp/manager";
25
25
  import vibeTurnResultTemplate from "../prompts/tools/vibe-turn-result.md" with { type: "text" };
26
26
  import { AgentLifecycleManager } from "../registry/agent-lifecycle";
27
- import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
27
+ import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
28
+ import type { SessionEntry } from "../session/session-entries";
29
+ import { SessionManager, SessionPersistenceIndeterminateError } from "../session/session-manager";
28
30
  import { getBundledAgent } from "../task/agents";
29
31
  import { type ExecutorOptions, runSubagentFollowUpTurn, runSubprocess } from "../task/executor";
30
32
  import { generateTaskName } from "../task/name-generator";
@@ -68,6 +70,82 @@ const TRACE_LINE_MAX = 120;
68
70
  const DEFAULT_WAIT_TIMEOUT_MS = 30_000;
69
71
  /** Response text cap inside a delivered turn result; full output stays at agent://<id>. */
70
72
  const RESPONSE_PREVIEW_MAX = 6000;
73
+ /** Grace period for abort-aware turns before teardown detaches a stuck provider/tool call. */
74
+ const CANCELLED_TURN_SETTLE_GRACE_MS = 250;
75
+
76
+ const VIBE_LIFECYCLE_CUSTOM_TYPE = "vibe-session-lifecycle";
77
+ const VIBE_LIFECYCLE_VERSION = 1;
78
+
79
+ export interface VibeOwnerScope {
80
+ ownerId: string;
81
+ parentSessionId: string;
82
+ parentSessionFile: string | null;
83
+ }
84
+
85
+ export interface VibeParentSession {
86
+ getAgentId?: () => string | null;
87
+ getSessionId?: () => string | null;
88
+ getSessionFile: () => string | null;
89
+ sessionManager?: ToolSession["sessionManager"] &
90
+ Partial<
91
+ Pick<SessionManager, "appendModeChange" | "appendEntriesAtomically" | "recoverPersistenceFromCurrentState">
92
+ >;
93
+ asyncJobManager?: AsyncJobManager;
94
+ settings: ToolSession["settings"];
95
+ getActiveModelString?: () => string | undefined;
96
+ getModelString?: () => string | undefined;
97
+ }
98
+
99
+ type VibeTombstoneReason = "explicit-kill" | "mode-exit" | "spawn-failed" | "unrecoverable";
100
+
101
+ interface VibeLifecycleBase {
102
+ version: typeof VIBE_LIFECYCLE_VERSION;
103
+ id: string;
104
+ ownerId: string;
105
+ parentSessionId: string;
106
+ }
107
+
108
+ interface VibeSpawnLifecycleEvent extends VibeLifecycleBase {
109
+ action: "spawn";
110
+ cli: VibeCli;
111
+ agent: string;
112
+ childSessionFile: string;
113
+ createdAt: number;
114
+ }
115
+
116
+ interface VibeTurnLifecycleEvent extends VibeLifecycleBase {
117
+ action: "turn-started" | "turn-settled";
118
+ turn: number;
119
+ }
120
+
121
+ interface VibeTombstoneLifecycleEvent extends VibeLifecycleBase {
122
+ action: "tombstone";
123
+ reason: VibeTombstoneReason;
124
+ }
125
+
126
+ interface VibeTombstoneRevocationEvent extends VibeLifecycleBase {
127
+ action: "tombstone-revoked";
128
+ reason: "mode-exit";
129
+ }
130
+
131
+ type VibeLifecycleEvent =
132
+ | VibeSpawnLifecycleEvent
133
+ | VibeTurnLifecycleEvent
134
+ | VibeTombstoneLifecycleEvent
135
+ | VibeTombstoneRevocationEvent;
136
+
137
+ interface VibeRestoreCandidate {
138
+ spawn: VibeSpawnLifecycleEvent;
139
+ turnCount: number;
140
+ lastActivityAt: number;
141
+ inFlight: boolean;
142
+ tombstoneReason?: VibeTombstoneReason;
143
+ }
144
+
145
+ interface ResolvedVibeWorker {
146
+ agent: AgentDefinition;
147
+ modelOverride?: string | string[];
148
+ }
71
149
 
72
150
  interface VibeTurn {
73
151
  jobId: string;
@@ -83,6 +161,9 @@ interface VibeRecord {
83
161
  id: string;
84
162
  cli: VibeCli;
85
163
  ownerId: string;
164
+ parentSessionId: string;
165
+ parentSessionFile: string | null;
166
+ childSessionFile?: string;
86
167
  agent: AgentDefinition;
87
168
  modelOverride?: string | string[];
88
169
  state: VibeSessionState;
@@ -107,6 +188,10 @@ interface VibeRecord {
107
188
  queue: string[];
108
189
  turnCount: number;
109
190
  killed: boolean;
191
+ /** True while a parent switch is detaching this process-local record without terminating it. */
192
+ suspended: boolean;
193
+ /** True only after a terminal lifecycle event has durably flushed. */
194
+ terminalPersisted: boolean;
110
195
  }
111
196
 
112
197
  /**
@@ -172,6 +257,87 @@ function firstLine(text: string, max = 100): string {
172
257
  return oneLineLabel(text, max);
173
258
  }
174
259
 
260
+ function scopeKey(scope: VibeOwnerScope, id: string): string {
261
+ return `${scope.parentSessionId}\0${scope.parentSessionFile ?? ""}\0${scope.ownerId}\0${id}`;
262
+ }
263
+
264
+ function matchesScope(record: VibeRecord, scope: VibeOwnerScope): boolean {
265
+ return (
266
+ record.ownerId === scope.ownerId &&
267
+ record.parentSessionId === scope.parentSessionId &&
268
+ record.parentSessionFile === scope.parentSessionFile
269
+ );
270
+ }
271
+
272
+ function objectRecord(value: unknown): Record<string, unknown> | undefined {
273
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
274
+ return value as Record<string, unknown>;
275
+ }
276
+
277
+ function parseLifecycleEvent(value: unknown): VibeLifecycleEvent | undefined {
278
+ const data = objectRecord(value);
279
+ if (!data || data.version !== VIBE_LIFECYCLE_VERSION) return undefined;
280
+ if (typeof data.id !== "string" || !data.id) return undefined;
281
+ if (typeof data.ownerId !== "string" || !data.ownerId) return undefined;
282
+ if (typeof data.parentSessionId !== "string" || !data.parentSessionId) return undefined;
283
+ const base: VibeLifecycleBase = {
284
+ version: VIBE_LIFECYCLE_VERSION,
285
+ id: data.id,
286
+ ownerId: data.ownerId,
287
+ parentSessionId: data.parentSessionId,
288
+ };
289
+ if (data.action === "spawn") {
290
+ const cli = data.cli === "fast" || data.cli === "good" ? data.cli : undefined;
291
+ if (!cli || typeof data.agent !== "string" || typeof data.childSessionFile !== "string") return undefined;
292
+ if (typeof data.createdAt !== "number" || !Number.isFinite(data.createdAt)) return undefined;
293
+ return {
294
+ ...base,
295
+ action: "spawn",
296
+ cli,
297
+ agent: data.agent,
298
+ childSessionFile: data.childSessionFile,
299
+ createdAt: data.createdAt,
300
+ };
301
+ }
302
+ if (data.action === "turn-started" || data.action === "turn-settled") {
303
+ if (typeof data.turn !== "number" || !Number.isInteger(data.turn) || data.turn < 1) return undefined;
304
+ return { ...base, action: data.action, turn: data.turn };
305
+ }
306
+ if (data.action === "tombstone") {
307
+ const reason = data.reason;
308
+ if (
309
+ reason !== "explicit-kill" &&
310
+ reason !== "mode-exit" &&
311
+ reason !== "spawn-failed" &&
312
+ reason !== "unrecoverable"
313
+ ) {
314
+ return undefined;
315
+ }
316
+ return { ...base, action: "tombstone", reason };
317
+ }
318
+ if (data.action === "tombstone-revoked" && data.reason === "mode-exit") {
319
+ return { ...base, action: "tombstone-revoked", reason: "mode-exit" };
320
+ }
321
+ return undefined;
322
+ }
323
+
324
+ /** Child ids claimed by any valid Vibe spawn event, independent of current parent scope. */
325
+ export function persistedVibeChildIds(entries: Iterable<SessionEntry>): Set<string> {
326
+ const ids = new Set<string>();
327
+ for (const entry of entries) {
328
+ if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
329
+ const event = parseLifecycleEvent(entry.data);
330
+ if (
331
+ event?.action === "spawn" &&
332
+ /^[A-Za-z0-9_-]+$/.test(event.id) &&
333
+ event.childSessionFile === `${event.id}.jsonl`
334
+ ) {
335
+ ids.add(event.id);
336
+ }
337
+ }
338
+ return ids;
339
+ }
340
+
175
341
  /** Merge the monitor's rolling `recentTools` window (newest first) into the per-turn trace (oldest first). */
176
342
  function mergeTrace(turn: VibeTurn, progress: AgentProgress): void {
177
343
  turn.toolCount = progress.toolCount;
@@ -188,10 +354,28 @@ function mergeTrace(turn: VibeTurn, progress: AgentProgress): void {
188
354
  /** Thrown from a turn job body so the job manager marks the job failed while carrying the formatted result. */
189
355
  export class VibeTurnError extends Error {}
190
356
 
357
+ async function awaitCancelledTurnJobs(jobs: ReadonlySet<AsyncJob>): Promise<void> {
358
+ if (jobs.size === 0) return;
359
+ const settled = Promise.allSettled([...jobs].map(job => job.promise)).then(() => true);
360
+ const timeout = Promise.withResolvers<false>();
361
+ const timer = setTimeout(() => timeout.resolve(false), CANCELLED_TURN_SETTLE_GRACE_MS);
362
+ timer.unref();
363
+ try {
364
+ if (!(await Promise.race([settled, timeout.promise]))) {
365
+ logger.warn("vibe: detached cancelled turn that did not settle within teardown grace period", {
366
+ jobCount: jobs.size,
367
+ graceMs: CANCELLED_TURN_SETTLE_GRACE_MS,
368
+ });
369
+ }
370
+ } finally {
371
+ clearTimeout(timer);
372
+ }
373
+ }
374
+
191
375
  /**
192
- * Process-global registry of vibe worker sessions, scoped per owner agent id
193
- * (same convention as AsyncJobManager owner filters). The interactive mode
194
- * kills an owner's sessions on vibe-mode exit via {@link killAll}.
376
+ * Process-global registry of vibe worker sessions, scoped by both owner agent
377
+ * id and stable parent session id. Persisted lifecycle events rebuild idle
378
+ * records after a process restart; live turn jobs remain process-local.
195
379
  */
196
380
  export class VibeSessionRegistry {
197
381
  static #global: VibeSessionRegistry | undefined;
@@ -218,6 +402,8 @@ export class VibeSessionRegistry {
218
402
  id: record.id,
219
403
  cli: record.cli ?? "fast",
220
404
  ownerId: record.ownerId,
405
+ parentSessionId: "test-parent-session",
406
+ parentSessionFile: null,
221
407
  agent: getBundledAgent("sonic")!,
222
408
  state: record.state ?? "running",
223
409
  createdAt: Date.now(),
@@ -225,10 +411,182 @@ export class VibeSessionRegistry {
225
411
  queue: [],
226
412
  turnCount: 0,
227
413
  killed: false,
414
+ suspended: false,
415
+ terminalPersisted: false,
228
416
  });
229
417
  }
230
418
 
231
419
  readonly #records = new Map<string, VibeRecord>();
420
+ readonly #terminationTails = new Map<string, Promise<void>>();
421
+ readonly #terminatedScopes = new Set<string>();
422
+
423
+ ownerScope(session: VibeParentSession): VibeOwnerScope {
424
+ const parentSessionId = session.getSessionId?.();
425
+ if (!parentSessionId) {
426
+ throw new ToolError("Vibe sessions require a stable parent session id.");
427
+ }
428
+ const parentSessionFile = session.getSessionFile();
429
+ return {
430
+ ownerId: session.getAgentId?.() ?? MAIN_AGENT_ID,
431
+ parentSessionId,
432
+ parentSessionFile: parentSessionFile ? path.resolve(parentSessionFile) : null,
433
+ };
434
+ }
435
+
436
+ /** Re-open spawn admission after an explicit Vibe-mode entry. */
437
+ activateScope(scope: VibeOwnerScope): void {
438
+ this.#terminatedScopes.delete(scopeKey(scope, ""));
439
+ }
440
+
441
+ async #withTerminationLock<T>(scope: VibeOwnerScope, operation: () => Promise<T>): Promise<T> {
442
+ const key = scopeKey(scope, "");
443
+ const predecessor = this.#terminationTails.get(key) ?? Promise.resolve();
444
+ const released = Promise.withResolvers<void>();
445
+ const tail = predecessor.then(() => released.promise);
446
+ this.#terminationTails.set(key, tail);
447
+ await predecessor;
448
+ try {
449
+ return await operation();
450
+ } finally {
451
+ released.resolve();
452
+ if (this.#terminationTails.get(key) === tail) this.#terminationTails.delete(key);
453
+ }
454
+ }
455
+
456
+ #resolveWorker(session: VibeParentSession, cli: VibeCli): ResolvedVibeWorker {
457
+ const agentName = VIBE_CLI_AGENT[cli];
458
+ const agent = getBundledAgent(agentName);
459
+ if (!agent) {
460
+ throw new ToolError(`Bundled agent "${agentName}" for vibe cli "${cli}" is unavailable.`);
461
+ }
462
+ const agentModelOverrides = session.settings.get("task.agentModelOverrides");
463
+ return {
464
+ agent,
465
+ modelOverride: resolveAgentModelPatterns({
466
+ settingsOverride: agentModelOverrides[agentName],
467
+ agentModel: agent.model,
468
+ settings: session.settings,
469
+ activeModelPattern: session.getActiveModelString?.(),
470
+ fallbackModelPattern: session.getModelString?.(),
471
+ }),
472
+ };
473
+ }
474
+
475
+ async #appendLifecycleEvent(
476
+ session: VibeParentSession,
477
+ event: VibeLifecycleEvent,
478
+ expectedParentSessionFile: string | null,
479
+ ): Promise<boolean> {
480
+ if (!expectedParentSessionFile || !session.sessionManager) return false;
481
+ const matchesCurrentScope = (): boolean => {
482
+ const currentSessionFile = session.getSessionFile();
483
+ return (
484
+ session.getSessionId?.() === event.parentSessionId &&
485
+ (session.getAgentId?.() ?? MAIN_AGENT_ID) === event.ownerId &&
486
+ currentSessionFile !== null &&
487
+ path.resolve(currentSessionFile) === expectedParentSessionFile
488
+ );
489
+ };
490
+ if (!matchesCurrentScope()) return false;
491
+ await session.sessionManager.ensureOnDisk();
492
+ if (!matchesCurrentScope()) return false;
493
+ session.sessionManager.appendCustomEntry(VIBE_LIFECYCLE_CUSTOM_TYPE, event);
494
+ await session.sessionManager.flush();
495
+ return true;
496
+ }
497
+
498
+ #eventBase(record: VibeRecord): VibeLifecycleBase {
499
+ return {
500
+ version: VIBE_LIFECYCLE_VERSION,
501
+ id: record.id,
502
+ ownerId: record.ownerId,
503
+ parentSessionId: record.parentSessionId,
504
+ };
505
+ }
506
+
507
+ async #appendTombstone(
508
+ session: VibeParentSession,
509
+ record: VibeRecord,
510
+ reason: VibeTombstoneReason,
511
+ ): Promise<boolean> {
512
+ return this.#appendLifecycleEvent(
513
+ session,
514
+ {
515
+ ...this.#eventBase(record),
516
+ action: "tombstone",
517
+ reason,
518
+ },
519
+ record.parentSessionFile,
520
+ );
521
+ }
522
+
523
+ #hasInMemoryTombstone(session: VibeParentSession, record: VibeRecord): boolean {
524
+ let terminalReason: VibeTombstoneReason | undefined;
525
+ for (const entry of session.sessionManager?.getEntries() ?? []) {
526
+ if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
527
+ const event = parseLifecycleEvent(entry.data);
528
+ if (
529
+ !event ||
530
+ event.id !== record.id ||
531
+ event.ownerId !== record.ownerId ||
532
+ event.parentSessionId !== record.parentSessionId
533
+ ) {
534
+ continue;
535
+ }
536
+ if (event.action === "tombstone") terminalReason = event.reason;
537
+ else if (event.action === "tombstone-revoked" && terminalReason === "mode-exit") terminalReason = undefined;
538
+ }
539
+ return terminalReason !== undefined;
540
+ }
541
+
542
+ async #persistModeExit(
543
+ session: VibeParentSession,
544
+ scope: VibeOwnerScope,
545
+ records: readonly VibeRecord[],
546
+ ): Promise<void> {
547
+ const pending = records.filter(record => !record.terminalPersisted);
548
+ const sessionManager = session.sessionManager;
549
+ if (!sessionManager) {
550
+ if (pending.some(record => record.childSessionFile)) {
551
+ throw new ToolError("Vibe mode exit cannot persist worker tombstones without the parent session manager.");
552
+ }
553
+ for (const record of pending) record.terminalPersisted = true;
554
+ return;
555
+ }
556
+ const currentScope = this.ownerScope(session);
557
+ if (
558
+ currentScope.ownerId !== scope.ownerId ||
559
+ currentScope.parentSessionId !== scope.parentSessionId ||
560
+ currentScope.parentSessionFile !== scope.parentSessionFile
561
+ ) {
562
+ throw new ToolError("Vibe parent session changed before mode exit could be persisted.");
563
+ }
564
+ const parentSessionFile = currentScope.parentSessionFile;
565
+ const persistedPending = pending.filter(record => record.childSessionFile !== undefined);
566
+ for (const record of persistedPending) {
567
+ if (
568
+ !parentSessionFile ||
569
+ path.resolve(parentSessionFile.slice(0, -6), `${record.id}.jsonl`) !== record.childSessionFile
570
+ ) {
571
+ throw new ToolError(`Vibe session "${record.id}" changed parent scope before termination.`);
572
+ }
573
+ }
574
+ const appendEntriesAtomically = sessionManager.appendEntriesAtomically;
575
+ if (!appendEntriesAtomically) {
576
+ throw new ToolError("Vibe mode exit requires atomic parent-session persistence.");
577
+ }
578
+ await appendEntriesAtomically.call(sessionManager, () => {
579
+ for (const record of persistedPending) {
580
+ sessionManager.appendCustomEntry(VIBE_LIFECYCLE_CUSTOM_TYPE, {
581
+ ...this.#eventBase(record),
582
+ action: "tombstone",
583
+ reason: "mode-exit",
584
+ });
585
+ }
586
+ sessionManager.appendModeChange?.("none");
587
+ });
588
+ for (const record of pending) record.terminalPersisted = true;
589
+ }
232
590
 
233
591
  #manager(session: ToolSession): AsyncJobManager {
234
592
  const manager = session.asyncJobManager;
@@ -238,10 +596,10 @@ export class VibeSessionRegistry {
238
596
  return manager;
239
597
  }
240
598
 
241
- #record(owner: string, id: string): VibeRecord {
242
- const record = this.#records.get(id.trim());
243
- if (!record || record.ownerId !== owner) {
244
- const roster = this.listIds(owner);
599
+ #record(scope: VibeOwnerScope, id: string): VibeRecord {
600
+ const record = this.#records.get(scopeKey(scope, id.trim()));
601
+ if (!record || !matchesScope(record, scope)) {
602
+ const roster = this.#listIds(scope);
245
603
  throw new ToolError(
246
604
  `Unknown vibe session "${id}".${roster.length > 0 ? ` Active sessions: ${roster.join(", ")}` : " No sessions — spawn one with vibe_spawn."}`,
247
605
  );
@@ -249,10 +607,30 @@ export class VibeSessionRegistry {
249
607
  return record;
250
608
  }
251
609
 
252
- listIds(owner: string): string[] {
610
+ #registeredAgent(record: VibeRecord): AgentRef | undefined {
611
+ const ref = AgentRegistry.global().get(record.id);
612
+ if (ref?.kind !== "sub" || ref.parentId !== record.ownerId) return undefined;
613
+ if (record.childSessionFile && ref.sessionFile !== record.childSessionFile) return undefined;
614
+ return ref;
615
+ }
616
+
617
+ #listIds(scope: VibeOwnerScope): string[] {
253
618
  const ids: string[] = [];
254
619
  for (const record of this.#records.values()) {
255
- if (record.ownerId === owner && record.state !== "dead") ids.push(record.id);
620
+ if (matchesScope(record, scope) && record.state !== "dead") ids.push(record.id);
621
+ }
622
+ return ids;
623
+ }
624
+
625
+ listIds(session: ToolSession): string[] {
626
+ return this.#listIds(this.ownerScope(session));
627
+ }
628
+
629
+ /** Ids of every live worker owned by `ownerId`, across all parent scopes (status-line tok/s aggregation). */
630
+ listIdsByOwner(ownerId: string): string[] {
631
+ const ids: string[] = [];
632
+ for (const record of this.#records.values()) {
633
+ if (record.ownerId === ownerId && record.state !== "dead") ids.push(record.id);
256
634
  }
257
635
  return ids;
258
636
  }
@@ -263,11 +641,12 @@ export class VibeSessionRegistry {
263
641
  * tool, and streamed text tail. All strings are one-line sanitized here so
264
642
  * renderers can print them verbatim.
265
643
  */
266
- screens(owner: string, ids?: string[]): VibeScreenSnapshot[] {
644
+ screens(session: ToolSession, ids?: string[]): VibeScreenSnapshot[] {
645
+ const scope = this.ownerScope(session);
267
646
  const wanted = ids?.length ? new Set(ids.map(id => id.trim())) : undefined;
268
647
  const records: VibeRecord[] = [];
269
648
  for (const record of this.#records.values()) {
270
- if (record.ownerId !== owner) continue;
649
+ if (!matchesScope(record, scope)) continue;
271
650
  if (wanted && !wanted.has(record.id)) continue;
272
651
  records.push(record);
273
652
  }
@@ -296,51 +675,272 @@ export class VibeSessionRegistry {
296
675
  }));
297
676
  }
298
677
 
299
- /** Spawn a persistent worker session and start its first turn in the background. */
300
- async spawn(session: ToolSession, args: { cli: VibeCli; name?: string; prompt: string }): Promise<VibeSpawnOutcome> {
301
- const owner = session.getAgentId?.() ?? MAIN_AGENT_ID;
302
- const manager = this.#manager(session);
303
- const agentName = VIBE_CLI_AGENT[args.cli];
304
- const agent = getBundledAgent(agentName);
305
- if (!agent) {
306
- throw new ToolError(`Bundled agent "${agentName}" for vibe cli "${args.cli}" is unavailable.`);
678
+ #persistedIds(session: VibeParentSession, scope: VibeOwnerScope): Set<string> {
679
+ const ids = new Set<string>();
680
+ for (const entry of session.sessionManager?.getEntries() ?? []) {
681
+ if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
682
+ const event = parseLifecycleEvent(entry.data);
683
+ if (event?.ownerId === scope.ownerId && event.parentSessionId === scope.parentSessionId) ids.add(event.id);
684
+ }
685
+ for (const record of this.#records.values()) {
686
+ if (matchesScope(record, scope)) ids.add(record.id);
307
687
  }
688
+ return ids;
689
+ }
308
690
 
309
- const agentModelOverrides = session.settings.get("task.agentModelOverrides");
310
- const modelOverride = resolveAgentModelPatterns({
311
- settingsOverride: agentModelOverrides[agentName],
312
- agentModel: agent.model,
313
- settings: session.settings,
314
- activeModelPattern: session.getActiveModelString?.(),
315
- fallbackModelPattern: session.getModelString?.(),
691
+ async #resolvePersistedChild(
692
+ parentSessionFile: string,
693
+ spawn: VibeSpawnLifecycleEvent,
694
+ options?: { requireAgentMatch?: boolean },
695
+ ): Promise<string | undefined> {
696
+ if (options?.requireAgentMatch !== false && spawn.agent !== VIBE_CLI_AGENT[spawn.cli]) return undefined;
697
+ if (!/^[A-Za-z0-9_-]+$/.test(spawn.id) || spawn.childSessionFile !== `${spawn.id}.jsonl`) return undefined;
698
+ const artifactsDir = path.resolve(parentSessionFile.slice(0, -6));
699
+ const childSessionFile = path.resolve(artifactsDir, spawn.childSessionFile);
700
+ const relative = path.relative(artifactsDir, childSessionFile);
701
+ if (!relative || path.isAbsolute(relative) || relative.startsWith(`..${path.sep}`) || relative === "..") {
702
+ return undefined;
703
+ }
704
+ try {
705
+ const persisted = await SessionManager.peekSessionInit(childSessionFile);
706
+ return persisted?.init ? childSessionFile : undefined;
707
+ } catch {
708
+ return undefined;
709
+ }
710
+ }
711
+
712
+ async #markTerminalRef(
713
+ id: string,
714
+ ownerId: string,
715
+ childSessionFile: string,
716
+ expected?: AgentRef | null,
717
+ ): Promise<void> {
718
+ const registry = AgentRegistry.global();
719
+ const existing = registry.get(id);
720
+ if (expected !== undefined && existing !== undefined && existing !== expected) return;
721
+ if (
722
+ existing &&
723
+ (existing.kind !== "sub" || existing.parentId !== ownerId || existing.sessionFile !== childSessionFile)
724
+ ) {
725
+ return;
726
+ }
727
+ if (existing?.status === "aborted" && !existing.session) return;
728
+ if (existing && !registry.setStatus(id, "aborted", existing)) return;
729
+ if (existing && AgentLifecycleManager.global().has(id, existing)) {
730
+ await AgentLifecycleManager.global().release(id, existing);
731
+ } else if (existing?.session) {
732
+ await existing.session.dispose();
733
+ }
734
+ const current = registry.get(id);
735
+ if (current && current !== existing) return;
736
+ if (current) registry.unregister(id, current);
737
+ registry.register({
738
+ id,
739
+ displayName: id,
740
+ kind: "sub",
741
+ parentId: ownerId,
742
+ session: null,
743
+ sessionFile: childSessionFile,
744
+ status: "aborted",
316
745
  });
746
+ }
747
+
748
+ /** Reconcile resumable and terminal workers from the persisted parent journal. */
749
+ async rehydrate(session: VibeParentSession): Promise<number> {
750
+ const sessionFile = session.getSessionFile();
751
+ const sessionManager = session.sessionManager;
752
+ if (!sessionFile || !sessionManager) return 0;
753
+ const scope = this.ownerScope(session);
754
+ const allSpawns = new Map<string, VibeSpawnLifecycleEvent>();
755
+ const terminalIntents = new Map<string, VibeTombstoneReason>();
756
+ for (const entry of sessionManager.getEntries()) {
757
+ if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
758
+ const event = parseLifecycleEvent(entry.data);
759
+ if (!event || event.ownerId !== scope.ownerId || event.parentSessionId !== scope.parentSessionId) continue;
760
+ if (event.action === "spawn") allSpawns.set(event.id, event);
761
+ else if (event.action === "tombstone") terminalIntents.set(event.id, event.reason);
762
+ else if (event.action === "tombstone-revoked" && terminalIntents.get(event.id) === "mode-exit") {
763
+ terminalIntents.delete(event.id);
764
+ }
765
+ }
766
+
767
+ const candidates = new Map<string, VibeRestoreCandidate>();
768
+ for (const entry of sessionManager.getBranch()) {
769
+ if (entry.type !== "custom" || entry.customType !== VIBE_LIFECYCLE_CUSTOM_TYPE) continue;
770
+ const event = parseLifecycleEvent(entry.data);
771
+ if (!event || event.ownerId !== scope.ownerId || event.parentSessionId !== scope.parentSessionId) continue;
772
+ const eventTime = Date.parse(entry.timestamp);
773
+ if (event.action === "spawn") {
774
+ candidates.set(event.id, {
775
+ spawn: event,
776
+ turnCount: 0,
777
+ lastActivityAt: Number.isFinite(eventTime) ? eventTime : event.createdAt,
778
+ inFlight: false,
779
+ });
780
+ continue;
781
+ }
782
+ const candidate = candidates.get(event.id);
783
+ if (!candidate) continue;
784
+ candidate.lastActivityAt = Number.isFinite(eventTime) ? eventTime : candidate.lastActivityAt;
785
+ if (event.action === "turn-started" && event.turn >= candidate.turnCount) {
786
+ candidate.turnCount = event.turn;
787
+ candidate.inFlight = true;
788
+ } else if (event.action === "turn-settled" && event.turn >= candidate.turnCount) {
789
+ candidate.turnCount = event.turn;
790
+ candidate.inFlight = false;
791
+ } else if (event.action === "tombstone") {
792
+ candidate.tombstoneReason = event.reason;
793
+ } else if (event.action === "tombstone-revoked" && candidate.tombstoneReason === "mode-exit") {
794
+ candidate.tombstoneReason = undefined;
795
+ }
796
+ }
797
+
798
+ for (const id of terminalIntents.keys()) {
799
+ const spawn = allSpawns.get(id);
800
+ if (!spawn) continue;
801
+ const childSessionFile = await this.#resolvePersistedChild(sessionFile, spawn, { requireAgentMatch: false });
802
+ if (!childSessionFile) continue;
803
+ await this.#markTerminalRef(id, scope.ownerId, childSessionFile);
804
+ this.#records.delete(scopeKey(scope, id));
805
+ }
806
+
807
+ let restored = 0;
808
+ for (const candidate of candidates.values()) {
809
+ const { spawn } = candidate;
810
+ if (candidate.tombstoneReason || terminalIntents.has(spawn.id) || candidate.turnCount < 1) continue;
811
+ const childSessionFile = await this.#resolvePersistedChild(sessionFile, spawn);
812
+ if (!childSessionFile) continue;
813
+ const key = scopeKey(scope, spawn.id);
814
+ if (this.#records.has(key)) continue;
815
+ const existing = AgentRegistry.global().get(spawn.id);
816
+ const existingIsResumable =
817
+ existing?.kind === "sub" &&
818
+ existing.parentId === scope.ownerId &&
819
+ existing.sessionFile === childSessionFile &&
820
+ (existing.status === "idle" || existing.status === "parked");
821
+ const blockedByCollision = Boolean(existing && !existingIsResumable);
822
+ const { agent, modelOverride } = this.#resolveWorker(session, spawn.cli);
823
+ if (!existing) {
824
+ AgentRegistry.global().register({
825
+ id: spawn.id,
826
+ displayName: spawn.id,
827
+ kind: "sub",
828
+ parentId: scope.ownerId,
829
+ session: null,
830
+ sessionFile: childSessionFile,
831
+ status: "parked",
832
+ });
833
+ }
834
+ this.#records.set(key, {
835
+ id: spawn.id,
836
+ cli: spawn.cli,
837
+ ownerId: scope.ownerId,
838
+ parentSessionId: scope.parentSessionId,
839
+ parentSessionFile: scope.parentSessionFile,
840
+ childSessionFile,
841
+ agent,
842
+ modelOverride,
843
+ state: "idle",
844
+ createdAt: spawn.createdAt,
845
+ lastActivityAt: candidate.lastActivityAt,
846
+ lastActivity: blockedByCollision
847
+ ? "blocked by an agent id collision"
848
+ : candidate.inFlight
849
+ ? `turn ${candidate.turnCount} interrupted by process restart`
850
+ : undefined,
851
+ queue: [],
852
+ turnCount: candidate.turnCount,
853
+ killed: false,
854
+ suspended: false,
855
+ terminalPersisted: false,
856
+ });
857
+ restored++;
858
+ }
859
+ return restored;
860
+ }
861
+
862
+ /** Spawn a persistent worker session and start its first turn in the background. */
863
+ async spawn(session: ToolSession, args: { cli: VibeCli; name?: string; prompt: string }): Promise<VibeSpawnOutcome> {
864
+ const scope = this.ownerScope(session);
865
+ return this.#withTerminationLock(scope, () => this.#spawnLocked(session, scope, args));
866
+ }
317
867
 
868
+ async #spawnLocked(
869
+ session: ToolSession,
870
+ scope: VibeOwnerScope,
871
+ args: { cli: VibeCli; name?: string; prompt: string },
872
+ ): Promise<VibeSpawnOutcome> {
873
+ if (this.#terminatedScopes.has(scopeKey(scope, ""))) {
874
+ throw new ToolError("Vibe mode has exited; enter Vibe mode again before spawning a worker.");
875
+ }
876
+ const manager = this.#manager(session);
877
+ const { agent, modelOverride } = this.#resolveWorker(session, args.cli);
318
878
  if (!session.agentOutputManager) {
319
879
  session.agentOutputManager = new AgentOutputManager(session.getArtifactsDir ?? (() => null));
320
880
  }
881
+ const reservedIds = this.#persistedIds(session, scope);
882
+ for (const ref of AgentRegistry.global().list()) reservedIds.add(ref.id);
883
+ await session.agentOutputManager.reserve(reservedIds);
321
884
  const requestedName = args.name?.replace(/[^A-Za-z0-9_-]+/g, "").slice(0, 48);
322
885
  const id = await session.agentOutputManager.allocate(requestedName || generateTaskName());
323
-
886
+ const parentSessionFile = scope.parentSessionFile;
887
+ const childSessionName = `${id}.jsonl`;
888
+ const childSessionFile = parentSessionFile
889
+ ? path.resolve(parentSessionFile.slice(0, -6), childSessionName)
890
+ : undefined;
891
+ const createdAt = Date.now();
324
892
  const record: VibeRecord = {
325
893
  id,
326
894
  cli: args.cli,
327
- ownerId: owner,
895
+ ownerId: scope.ownerId,
896
+ parentSessionId: scope.parentSessionId,
897
+ parentSessionFile,
898
+ childSessionFile,
328
899
  agent,
329
900
  modelOverride,
330
901
  state: "starting",
331
- createdAt: Date.now(),
332
- lastActivityAt: Date.now(),
902
+ createdAt,
903
+ lastActivityAt: createdAt,
333
904
  queue: [],
334
905
  turnCount: 0,
335
906
  killed: false,
907
+ suspended: false,
908
+ terminalPersisted: false,
336
909
  };
337
- this.#records.set(id, record);
338
-
910
+ const key = scopeKey(scope, id);
911
+ this.#records.set(key, record);
912
+ let spawnPersisted = false;
339
913
  try {
914
+ if (childSessionFile) {
915
+ spawnPersisted = await this.#appendLifecycleEvent(
916
+ session,
917
+ {
918
+ ...this.#eventBase(record),
919
+ action: "spawn",
920
+ cli: args.cli,
921
+ agent: agent.name,
922
+ childSessionFile: childSessionName,
923
+ createdAt,
924
+ },
925
+ record.parentSessionFile,
926
+ );
927
+ if (!spawnPersisted) throw new ToolError("Vibe parent session changed before the worker could start.");
928
+ }
340
929
  const jobId = this.#registerTurnJob(session, manager, record, args.prompt, { first: true });
341
930
  return { id, jobId };
342
931
  } catch (error) {
343
- this.#records.delete(id);
932
+ record.killed = true;
933
+ record.state = "dead";
934
+ record.lastActivityAt = Date.now();
935
+ record.lastActivity = "spawn failed";
936
+ if (childSessionFile) {
937
+ // A rejected terminal write leaves this dead record in the map so mode exit can retry it.
938
+ record.terminalPersisted = await this.#appendTombstone(session, record, "spawn-failed");
939
+ if (!record.terminalPersisted) {
940
+ throw new ToolError("Vibe parent session changed before spawn failure could be persisted.");
941
+ }
942
+ }
943
+ this.#records.delete(key);
344
944
  throw error;
345
945
  }
346
946
  }
@@ -351,16 +951,20 @@ export class VibeSessionRegistry {
351
951
  * background turn immediately.
352
952
  */
353
953
  async send(session: ToolSession, args: { session: string; message: string }): Promise<VibeSendOutcome> {
354
- const owner = session.getAgentId?.() ?? MAIN_AGENT_ID;
355
- const record = this.#record(owner, args.session);
954
+ const scope = this.ownerScope(session);
955
+ const record = this.#record(scope, args.session);
356
956
  if (record.state === "dead") {
357
957
  throw new ToolError(`Vibe session "${record.id}" is dead. Spawn a new one with vibe_spawn.`);
358
958
  }
359
959
  const message = args.message.trim();
360
960
  if (!message) throw new ToolError("Message must not be empty.");
961
+ const registered = this.#registeredAgent(record);
962
+ if (AgentRegistry.global().get(record.id) && !registered) {
963
+ throw new ToolError(`Vibe session "${record.id}" no longer resolves to this parent session.`);
964
+ }
361
965
 
362
966
  if (record.turn) {
363
- const live = AgentRegistry.global().get(record.id)?.session;
967
+ const live = registered?.session;
364
968
  if (live?.isStreaming) {
365
969
  await live.steer(message);
366
970
  record.lastActivityAt = Date.now();
@@ -371,6 +975,10 @@ export class VibeSessionRegistry {
371
975
  return { id: record.id, mode: "queued" };
372
976
  }
373
977
 
978
+ if (!registered || (registered.status !== "idle" && registered.status !== "parked")) {
979
+ throw new ToolError(`Vibe session "${record.id}" no longer resolves to this parent session.`);
980
+ }
981
+
374
982
  const manager = this.#manager(session);
375
983
  const jobId = this.#registerTurnJob(session, manager, record, message, { first: false });
376
984
  return { id: record.id, mode: "turn", jobId };
@@ -386,14 +994,14 @@ export class VibeSessionRegistry {
386
994
  session: ToolSession,
387
995
  args: { sessions?: string[]; timeoutMs?: number; signal?: AbortSignal },
388
996
  ): Promise<VibeWaitOutcome> {
389
- const owner = session.getAgentId?.() ?? MAIN_AGENT_ID;
997
+ const scope = this.ownerScope(session);
390
998
  const manager = this.#manager(session);
391
999
  // Named sessions are watched regardless of state (a just-settled turn is
392
1000
  // reported from its retained job); the no-args form watches every
393
1001
  // session with a turn actually in flight.
394
1002
  const watched = args.sessions?.length
395
- ? args.sessions.map(id => this.#record(owner, id))
396
- : [...this.#records.values()].filter(record => record.ownerId === owner && record.turn !== undefined);
1003
+ ? args.sessions.map(id => this.#record(scope, id))
1004
+ : [...this.#records.values()].filter(record => matchesScope(record, scope) && record.turn !== undefined);
397
1005
 
398
1006
  // Snapshot each watched turn's job at entry: #finishTurn installs a
399
1007
  // queued follow-up turn inside the settling job's callback (before that
@@ -461,41 +1069,179 @@ export class VibeSessionRegistry {
461
1069
  return { settled, stillRunning, timedOut: waited && settled.length === 0 };
462
1070
  }
463
1071
 
464
- /** Terminate a worker: cancel its in-flight turn and dispose + unregister its session. */
1072
+ /** Detach one parent's process-local workers without tombstoning their persisted conversations. */
1073
+ async suspendScope(scope: VibeOwnerScope, manager?: AsyncJobManager): Promise<number> {
1074
+ const records = [...this.#records.values()].filter(record => matchesScope(record, scope));
1075
+ const teardown = records.map(record => ({
1076
+ record,
1077
+ ref: this.#registeredAgent(record),
1078
+ job: record.turn && manager ? manager.getJob(record.turn.jobId) : undefined,
1079
+ }));
1080
+ for (const { record } of teardown) {
1081
+ record.suspended = true;
1082
+ record.queue.length = 0;
1083
+ record.state = "dead";
1084
+ record.lastActivityAt = Date.now();
1085
+ record.lastActivity = "suspended for parent-session switch";
1086
+ this.#records.delete(scopeKey(scope, record.id));
1087
+ if (record.turn && manager) manager.cancel(record.turn.jobId, { ownerId: record.ownerId });
1088
+ }
1089
+ for (const { record, ref } of teardown) {
1090
+ if (!ref) continue;
1091
+ try {
1092
+ await AgentLifecycleManager.global().release(record.id, ref);
1093
+ } catch (error) {
1094
+ logger.warn("vibe: failed to detach worker session", {
1095
+ id: record.id,
1096
+ error: error instanceof Error ? error.message : String(error),
1097
+ });
1098
+ }
1099
+ }
1100
+ await awaitCancelledTurnJobs(new Set(teardown.flatMap(entry => (entry.job ? [entry.job] : []))));
1101
+ return records.length;
1102
+ }
1103
+
1104
+ /** Terminate one worker; a tombstone failure still tears it down before reconciliation and error delivery. */
465
1105
  async kill(session: ToolSession, id: string): Promise<VibeKillOutcome> {
466
- const owner = session.getAgentId?.() ?? MAIN_AGENT_ID;
467
- const record = this.#record(owner, id);
468
- return this.#killRecord(record, session.asyncJobManager);
1106
+ const scope = this.ownerScope(session);
1107
+ return this.#withTerminationLock(scope, () => {
1108
+ const record = this.#record(scope, id);
1109
+ return this.#killRecord(record, session.asyncJobManager, session, "explicit-kill");
1110
+ });
469
1111
  }
470
1112
 
471
- /** Kill every session belonging to `owner` (vibe-mode exit / teardown). Returns the number killed. */
472
- async killAll(owner: string, manager?: AsyncJobManager): Promise<number> {
473
- let killed = 0;
474
- for (const record of this.#records.values()) {
475
- if (record.ownerId !== owner || record.state === "dead") continue;
476
- await this.#killRecord(record, manager);
477
- killed++;
1113
+ /** Kill every live session in one parent scope after durably recording the complete mode-exit intent. */
1114
+ async killAll(session: VibeParentSession, ownerScope?: VibeOwnerScope): Promise<number> {
1115
+ const scope = ownerScope ?? this.ownerScope(session);
1116
+ return this.#withTerminationLock(scope, async () => {
1117
+ try {
1118
+ const killed = await this.#killAllLocked(session, scope);
1119
+ this.#terminatedScopes.add(scopeKey(scope, ""));
1120
+ return killed;
1121
+ } catch (error) {
1122
+ if (error instanceof SessionPersistenceIndeterminateError) {
1123
+ this.#terminatedScopes.add(scopeKey(scope, ""));
1124
+ }
1125
+ throw error;
1126
+ }
1127
+ });
1128
+ }
1129
+
1130
+ async #killAllLocked(session: VibeParentSession, scope: VibeOwnerScope): Promise<number> {
1131
+ const records = [...this.#records.values()].filter(
1132
+ record => matchesScope(record, scope) && !(record.state === "dead" && record.terminalPersisted),
1133
+ );
1134
+ if (records.length === 0) {
1135
+ const entries = session.sessionManager?.getBranch() ?? [];
1136
+ for (let index = entries.length - 1; index >= 0; index--) {
1137
+ const entry = entries[index];
1138
+ if (entry.type !== "mode_change") continue;
1139
+ if (entry.mode === "none") return 0;
1140
+ break;
1141
+ }
1142
+ }
1143
+ try {
1144
+ await this.#persistModeExit(session, scope, records);
1145
+ } catch (error) {
1146
+ if (error instanceof SessionPersistenceIndeterminateError) {
1147
+ for (const record of records) {
1148
+ await this.#killRecord(record, session.asyncJobManager, session, "mode-exit", false);
1149
+ }
1150
+ }
1151
+ throw error;
1152
+ }
1153
+ for (const record of records) {
1154
+ await this.#killRecord(record, session.asyncJobManager, session, "mode-exit");
478
1155
  }
479
- return killed;
1156
+ return records.length;
480
1157
  }
481
1158
 
482
- async #killRecord(record: VibeRecord, manager: AsyncJobManager | undefined): Promise<VibeKillOutcome> {
1159
+ async #killRecord(
1160
+ record: VibeRecord,
1161
+ manager: AsyncJobManager | undefined,
1162
+ session: VibeParentSession,
1163
+ reason: VibeTombstoneReason,
1164
+ persistTerminal = true,
1165
+ ): Promise<VibeKillOutcome> {
1166
+ const registered = this.#registeredAgent(record);
1167
+ const settlingJobs = new Set<AsyncJob>();
1168
+ if (record.turn && manager) {
1169
+ const job = manager.getJob(record.turn.jobId);
1170
+ if (job) settlingJobs.add(job);
1171
+ }
1172
+ let persistenceError: unknown;
1173
+ if (persistTerminal && !record.terminalPersisted) {
1174
+ try {
1175
+ if (record.killed) {
1176
+ const recover = session.sessionManager?.recoverPersistenceFromCurrentState;
1177
+ if (!recover) throw new ToolError("Vibe tombstone recovery requires parent-session persistence.");
1178
+ await recover.call(session.sessionManager);
1179
+ }
1180
+ if (!this.#hasInMemoryTombstone(session, record) && record.childSessionFile) {
1181
+ if (!(await this.#appendTombstone(session, record, reason))) {
1182
+ throw new ToolError(`Vibe session "${record.id}" changed parent scope before termination.`);
1183
+ }
1184
+ }
1185
+ record.terminalPersisted = true;
1186
+ } catch (error) {
1187
+ persistenceError = error;
1188
+ }
1189
+ }
483
1190
  record.killed = true;
484
1191
  record.queue.length = 0;
485
1192
  let cancelledTurn = false;
486
1193
  if (record.turn && manager) {
1194
+ const job = manager.getJob(record.turn.jobId);
1195
+ if (job) settlingJobs.add(job);
487
1196
  cancelledTurn = manager.cancel(record.turn.jobId, { ownerId: record.ownerId });
488
1197
  }
489
1198
  record.state = "dead";
490
1199
  record.lastActivityAt = Date.now();
491
1200
  record.lastActivity = "killed";
492
- try {
493
- await AgentLifecycleManager.global().release(record.id);
494
- } catch (error) {
495
- logger.warn("vibe: failed to release worker session", {
496
- id: record.id,
497
- error: error instanceof Error ? error.message : String(error),
498
- });
1201
+ if (registered) {
1202
+ try {
1203
+ await AgentLifecycleManager.global().release(record.id, registered);
1204
+ } catch (error) {
1205
+ logger.warn("vibe: failed to release worker session", {
1206
+ id: record.id,
1207
+ error: error instanceof Error ? error.message : String(error),
1208
+ });
1209
+ }
1210
+ }
1211
+ await awaitCancelledTurnJobs(settlingJobs);
1212
+ const terminalRef = registered ?? this.#registeredAgent(record) ?? null;
1213
+ if (record.childSessionFile) {
1214
+ try {
1215
+ const persisted = await SessionManager.peekSessionInit(record.childSessionFile);
1216
+ if (persisted?.init)
1217
+ await this.#markTerminalRef(record.id, record.ownerId, record.childSessionFile, terminalRef);
1218
+ } catch {
1219
+ // A turn killed before session initialization has no readable history to retain.
1220
+ }
1221
+ }
1222
+ if (persistenceError) {
1223
+ let finalPersistenceError = persistenceError;
1224
+ const recover = session.sessionManager?.recoverPersistenceFromCurrentState;
1225
+ if (recover) {
1226
+ try {
1227
+ await recover.call(session.sessionManager);
1228
+ if (!this.#hasInMemoryTombstone(session, record) && record.childSessionFile) {
1229
+ if (!(await this.#appendTombstone(session, record, reason))) {
1230
+ throw new ToolError(`Vibe session "${record.id}" changed parent scope before termination.`);
1231
+ }
1232
+ }
1233
+ record.terminalPersisted = true;
1234
+ } catch (recoveryError) {
1235
+ if (recoveryError instanceof SessionPersistenceIndeterminateError) {
1236
+ finalPersistenceError = recoveryError;
1237
+ }
1238
+ logger.warn("vibe: failed to reconcile explicit tombstone persistence", {
1239
+ id: record.id,
1240
+ error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
1241
+ });
1242
+ }
1243
+ }
1244
+ throw finalPersistenceError;
499
1245
  }
500
1246
  return { id: record.id, cancelledTurn };
501
1247
  }
@@ -601,6 +1347,18 @@ export class VibeSessionRegistry {
601
1347
  record.turnCount = turnIndex;
602
1348
  record.lastActivityAt = Date.now();
603
1349
  try {
1350
+ const turnStartedPersisted = await this.#appendLifecycleEvent(
1351
+ session,
1352
+ {
1353
+ ...this.#eventBase(record),
1354
+ action: "turn-started",
1355
+ turn: turnIndex,
1356
+ },
1357
+ record.parentSessionFile,
1358
+ );
1359
+ if (record.childSessionFile && !turnStartedPersisted) {
1360
+ throw new ToolError(`Vibe session "${record.id}" changed parent scope before its turn started.`);
1361
+ }
604
1362
  const result = options.first
605
1363
  ? await runSubprocess(await this.#buildSpawnOptions(session, record, message, signal, onProgress))
606
1364
  : await runSubagentFollowUpTurn({
@@ -613,10 +1371,10 @@ export class VibeSessionRegistry {
613
1371
  eventBus: session.eventBus,
614
1372
  artifactsDir: session.getSessionFile()?.slice(0, -6),
615
1373
  });
616
- return this.#settleTurn(session, manager, record, turn, ownJobId, turnIndex, result);
1374
+ return await this.#settleTurn(session, manager, record, turn, ownJobId, turnIndex, result);
617
1375
  } catch (error) {
618
1376
  if (error instanceof VibeTurnError) throw error;
619
- this.#finishTurn(session, manager, record, ownJobId);
1377
+ await this.#finishTurn(session, manager, record, ownJobId);
620
1378
  const reason = error instanceof Error ? error.message : String(error);
621
1379
  record.lastActivity = firstLine(`turn failed: ${reason}`);
622
1380
  throw new VibeTurnError(
@@ -632,19 +1390,41 @@ export class VibeSessionRegistry {
632
1390
  }
633
1391
 
634
1392
  /** Post-turn bookkeeping shared by success and failure paths: clear the in-flight turn, flush the queue. */
635
- #finishTurn(session: ToolSession, manager: AsyncJobManager, record: VibeRecord, settledJobId: string): void {
1393
+ async #finishTurn(
1394
+ session: ToolSession,
1395
+ manager: AsyncJobManager,
1396
+ record: VibeRecord,
1397
+ settledJobId: string,
1398
+ ): Promise<void> {
636
1399
  record.lastJobId = settledJobId;
637
1400
  record.turn = undefined;
638
1401
  record.live = undefined;
639
1402
  record.lastActivityAt = Date.now();
640
- if (record.killed) {
1403
+ if (record.killed || record.suspended) {
1404
+ record.state = "dead";
1405
+ return;
1406
+ }
1407
+ // Only an idle/parked ref with this parent's exact child file is resumable.
1408
+ const registered = this.#registeredAgent(record);
1409
+ record.state = registered && (registered.status === "idle" || registered.status === "parked") ? "idle" : "dead";
1410
+ if (record.state === "dead") {
1411
+ record.terminalPersisted = await this.#appendTombstone(session, record, "unrecoverable");
1412
+ return;
1413
+ }
1414
+ const settledPersisted = await this.#appendLifecycleEvent(
1415
+ session,
1416
+ {
1417
+ ...this.#eventBase(record),
1418
+ action: "turn-settled",
1419
+ turn: record.turnCount,
1420
+ },
1421
+ record.parentSessionFile,
1422
+ );
1423
+ if (record.childSessionFile && !settledPersisted) {
641
1424
  record.state = "dead";
642
1425
  return;
643
1426
  }
644
- // A spawn that failed before its session ever registered leaves nothing
645
- // to continue — mark the record dead so sends fail with clear guidance.
646
- record.state = AgentRegistry.global().get(record.id) ? "idle" : "dead";
647
- if (record.state === "dead" || record.queue.length === 0) return;
1427
+ if (record.queue.length === 0) return;
648
1428
  const nextMessage = record.queue.splice(0, record.queue.length).join("\n\n");
649
1429
  try {
650
1430
  this.#registerTurnJob(session, manager, record, nextMessage, { first: false });
@@ -659,7 +1439,7 @@ export class VibeSessionRegistry {
659
1439
  }
660
1440
 
661
1441
  /** Format a settled turn into the self-delivering result text (activity trace + response). */
662
- #settleTurn(
1442
+ async #settleTurn(
663
1443
  session: ToolSession,
664
1444
  manager: AsyncJobManager,
665
1445
  record: VibeRecord,
@@ -667,8 +1447,8 @@ export class VibeSessionRegistry {
667
1447
  settledJobId: string,
668
1448
  turnIndex: number,
669
1449
  result: SingleResult,
670
- ): string {
671
- this.#finishTurn(session, manager, record, settledJobId);
1450
+ ): Promise<string> {
1451
+ await this.#finishTurn(session, manager, record, settledJobId);
672
1452
  const failed = result.exitCode !== 0 || result.aborted === true;
673
1453
  const status = result.aborted ? "aborted" : failed ? "failed" : "completed";
674
1454
  record.lastActivity = firstLine(
@@ -742,7 +1522,7 @@ export class VibeSessionRegistry {
742
1522
  * computed identically to the main session's rate.
743
1523
  */
744
1524
  export function aggregateVibeWorkerTokensPerSecond(ownerId: string): number | null {
745
- const ids = VibeSessionRegistry.global().listIds(ownerId);
1525
+ const ids = VibeSessionRegistry.global().listIdsByOwner(ownerId);
746
1526
  if (ids.length === 0) return null;
747
1527
  let total = 0;
748
1528
  let any = false;