vibe-coding-master 0.3.25 → 0.3.26

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.
@@ -31,6 +31,8 @@ const VCM_HOOK_DEFINITIONS = [
31
31
  { eventName: "PreToolUse", matcher: "Bash", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
32
32
  { eventName: "UserPromptSubmit", command: VCM_HOOK_COMMAND, timeout: 5 },
33
33
  { eventName: "Stop", command: VCM_STOP_HOOK_COMMAND, timeout: 10 },
34
+ { eventName: "StopFailure", command: VCM_HOOK_COMMAND, timeout: 5 },
35
+ { eventName: "PostCompact", command: VCM_HOOK_COMMAND, timeout: 5 },
34
36
  { eventName: "PermissionRequest", command: VCM_PERMISSION_REQUEST_HOOK_COMMAND, timeout: 5 }
35
37
  ];
36
38
  const AGENT_FRONTMATTER = {
@@ -266,6 +266,7 @@ export function createDefaultServerDeps(options = {}) {
266
266
  roundService,
267
267
  translationService,
268
268
  appSettings,
269
+ runtime,
269
270
  gatewayService,
270
271
  jobGuard: createJobGuardService()
271
272
  });
@@ -1,7 +1,10 @@
1
1
  import { isVcmRoleName } from "../../shared/constants.js";
2
2
  import { VcmError } from "../errors.js";
3
+ import { submitTerminalInput } from "../runtime/terminal-submit.js";
3
4
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
5
+ const MAX_STOP_FAILURE_RECOVERY_ATTEMPTS = 2;
4
6
  export function createClaudeHookService(deps) {
7
+ const stopFailureRecoveryAttempts = new Map();
5
8
  async function getHookContext(input) {
6
9
  if (!isVcmRoleName(input.role)) {
7
10
  throw new VcmError({
@@ -92,6 +95,7 @@ export function createClaudeHookService(deps) {
92
95
  throwUnsupportedEvent(eventName);
93
96
  }
94
97
  const context = await getHookContext(input);
98
+ clearStopFailureRecovery(context.project.repoRoot, input.taskSlug, input.role);
95
99
  if (options.allowBlock && deps.jobGuard) {
96
100
  const verdict = await deps.jobGuard.evaluateStop({
97
101
  repoRoot: context.project.repoRoot,
@@ -113,23 +117,72 @@ export function createClaudeHookService(deps) {
113
117
  };
114
118
  }
115
119
  }
116
- const scopedRouteDispatchInput = {
117
- repoRoot: context.project.repoRoot,
118
- taskRepoRoot: context.taskRepoRoot,
119
- stateRepoRoot: context.taskRepoRoot,
120
- stateRoot: context.config.stateRoot,
121
- handoffDir: context.task.handoffDir,
120
+ return recordTurnEnd(input, context, eventName, {
121
+ dispatchRouteFiles: true,
122
+ notifyGateway: true,
123
+ settleGuard: true
124
+ });
125
+ }
126
+ async function processStopFailureHook(input) {
127
+ const eventName = parseHookEvent(input.event.hook_event_name);
128
+ if (eventName !== "StopFailure") {
129
+ throwUnsupportedEvent(eventName);
130
+ }
131
+ const context = await getHookContext(input);
132
+ const routeDispatchInput = createRouteDispatchInput(input, context);
133
+ const pending = await deps.messageService.listPendingRouteFiles(routeDispatchInput);
134
+ const hasCompletionEvidence = pending.some((routeFile) => routeFile.fromRole === input.role);
135
+ if (hasCompletionEvidence) {
136
+ clearStopFailureRecovery(context.project.repoRoot, input.taskSlug, input.role);
137
+ return recordTurnEnd(input, context, eventName, {
138
+ dispatchRouteFiles: true,
139
+ notifyGateway: false,
140
+ settleGuard: true
141
+ });
142
+ }
143
+ const recovered = await dispatchStopFailureRecovery(input, context);
144
+ if (recovered) {
145
+ return {
146
+ ok: true,
147
+ eventName,
148
+ taskSlug: input.taskSlug,
149
+ role: input.role,
150
+ sessionUpdated: true,
151
+ dispatchedCount: 0
152
+ };
153
+ }
154
+ return recordTurnEnd(input, context, eventName, {
155
+ dispatchRouteFiles: false,
156
+ notifyGateway: false,
157
+ settleGuard: false
158
+ });
159
+ }
160
+ async function processPostCompactHook(input) {
161
+ const eventName = parseHookEvent(input.event.hook_event_name);
162
+ if (eventName !== "PostCompact") {
163
+ throwUnsupportedEvent(eventName);
164
+ }
165
+ const context = await getHookContext(input);
166
+ const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
122
167
  taskSlug: input.taskSlug,
123
- stoppedRole: input.role
124
- };
125
- const settleRouteDispatchInput = {
126
- repoRoot: context.project.repoRoot,
127
- taskRepoRoot: context.taskRepoRoot,
128
- stateRepoRoot: context.taskRepoRoot,
129
- stateRoot: context.config.stateRoot,
130
- handoffDir: context.task.handoffDir,
131
- taskSlug: input.taskSlug
168
+ role: input.role,
169
+ eventName,
170
+ claudeSessionId: stringOrUndefined(input.event.session_id),
171
+ transcriptPath: stringOrUndefined(input.event.transcript_path),
172
+ cwd: stringOrUndefined(input.event.cwd)
173
+ });
174
+ return {
175
+ ok: true,
176
+ eventName,
177
+ taskSlug: input.taskSlug,
178
+ role: input.role,
179
+ sessionUpdated: Boolean(session),
180
+ dispatchedCount: 0
132
181
  };
182
+ }
183
+ async function recordTurnEnd(input, context, eventName, options) {
184
+ const scopedRouteDispatchInput = createRouteDispatchInput(input, context, input.role);
185
+ const settleRouteDispatchInput = createRouteDispatchInput(input, context);
133
186
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
134
187
  taskSlug: input.taskSlug,
135
188
  role: input.role,
@@ -145,16 +198,20 @@ export function createClaudeHookService(deps) {
145
198
  taskSlug: input.taskSlug,
146
199
  role: input.role,
147
200
  eventName,
148
- settleGuard: async () => {
149
- const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
150
- if (pending.length === 0) {
151
- return { action: "stop" };
201
+ ...(options.settleGuard
202
+ ? {
203
+ settleGuard: async () => {
204
+ const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
205
+ if (pending.length === 0) {
206
+ return { action: "stop" };
207
+ }
208
+ const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
209
+ return retried.some((result) => result.delivered)
210
+ ? { action: "continue", reason: "pending route message dispatched" }
211
+ : { action: "stop" };
212
+ }
152
213
  }
153
- const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
154
- return retried.some((result) => result.delivered)
155
- ? { action: "continue", reason: "pending route message dispatched" }
156
- : { action: "stop" };
157
- }
214
+ : {})
158
215
  });
159
216
  if (session) {
160
217
  await deps.translationService.recordConversationBoundary({
@@ -167,14 +224,16 @@ export function createClaudeHookService(deps) {
167
224
  occurredAt: session.lastTurnEndedAt ?? session.updatedAt
168
225
  });
169
226
  }
170
- if (session && input.role === "project-manager") {
227
+ if (options.notifyGateway && session && input.role === "project-manager") {
171
228
  void deps.gatewayService?.handlePmStop({
172
229
  repoRoot: context.project.repoRoot,
173
230
  taskSlug: input.taskSlug,
174
231
  session
175
232
  }).catch(() => undefined);
176
233
  }
177
- const dispatched = await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput);
234
+ const dispatched = options.dispatchRouteFiles
235
+ ? await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput)
236
+ : [];
178
237
  return {
179
238
  ok: true,
180
239
  eventName,
@@ -184,6 +243,51 @@ export function createClaudeHookService(deps) {
184
243
  dispatchedCount: dispatched.filter((result) => result.delivered).length
185
244
  };
186
245
  }
246
+ function createRouteDispatchInput(input, context, stoppedRole) {
247
+ return {
248
+ repoRoot: context.project.repoRoot,
249
+ taskRepoRoot: context.taskRepoRoot,
250
+ stateRepoRoot: context.taskRepoRoot,
251
+ stateRoot: context.config.stateRoot,
252
+ handoffDir: context.task.handoffDir,
253
+ taskSlug: input.taskSlug,
254
+ ...(stoppedRole ? { stoppedRole } : {})
255
+ };
256
+ }
257
+ async function dispatchStopFailureRecovery(input, context) {
258
+ if (!deps.runtime) {
259
+ return false;
260
+ }
261
+ const key = stopFailureRecoveryKey(context.project.repoRoot, input.taskSlug, input.role);
262
+ const attempt = (stopFailureRecoveryAttempts.get(key) ?? 0) + 1;
263
+ if (attempt > MAX_STOP_FAILURE_RECOVERY_ATTEMPTS) {
264
+ return false;
265
+ }
266
+ const session = await deps.sessionService.getRoleSession(context.project.repoRoot, input.taskSlug, input.role);
267
+ if (!session || session.status !== "running") {
268
+ return false;
269
+ }
270
+ stopFailureRecoveryAttempts.set(key, attempt);
271
+ await submitTerminalInput(deps.runtime, session.id, renderStopFailureRecoveryPrompt(attempt));
272
+ await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, input.taskSlug, input.role);
273
+ return true;
274
+ }
275
+ function clearStopFailureRecovery(repoRoot, taskSlug, role) {
276
+ stopFailureRecoveryAttempts.delete(stopFailureRecoveryKey(repoRoot, taskSlug, role));
277
+ }
278
+ function stopFailureRecoveryKey(repoRoot, taskSlug, role) {
279
+ return `${repoRoot}:${taskSlug}:${role}`;
280
+ }
281
+ function renderStopFailureRecoveryPrompt(attempt) {
282
+ return [
283
+ "[VCM Recovery]",
284
+ "Your previous turn ended unexpectedly after context compaction or an API error.",
285
+ "Continue the same assigned work from the current repository and VCM handoff state.",
286
+ "Do not repeat completed edits, duplicate validation, or duplicate route messages.",
287
+ "If the assigned work is already complete, write/send the expected VCM handoff now.",
288
+ `Recovery attempt: ${attempt}/${MAX_STOP_FAILURE_RECOVERY_ATTEMPTS}.`
289
+ ].join("\n");
290
+ }
187
291
  async function handlePermissionRequestHook(input) {
188
292
  if (!isVcmRoleName(input.role)) {
189
293
  throw new VcmError({
@@ -220,6 +324,12 @@ export function createClaudeHookService(deps) {
220
324
  if (eventName === "UserPromptSubmit") {
221
325
  return handleUserPromptSubmitHook(input);
222
326
  }
327
+ if (eventName === "StopFailure") {
328
+ return processStopFailureHook(input);
329
+ }
330
+ if (eventName === "PostCompact") {
331
+ return processPostCompactHook(input);
332
+ }
223
333
  // Legacy combined endpoint: the installed hook discards the response,
224
334
  // so a block decision could not be enforced. Never block here.
225
335
  return processStopHook(input, { allowBlock: false });
@@ -231,14 +341,14 @@ export function createClaudeHookService(deps) {
231
341
  };
232
342
  }
233
343
  function parseHookEvent(value) {
234
- if (value === "UserPromptSubmit" || value === "Stop") {
344
+ if (value === "UserPromptSubmit" || value === "Stop" || value === "StopFailure" || value === "PostCompact") {
235
345
  return value;
236
346
  }
237
347
  throw new VcmError({
238
348
  code: "HOOK_EVENT_UNSUPPORTED",
239
349
  message: `Unsupported Claude Code hook event: ${String(value)}`,
240
350
  statusCode: 400,
241
- hint: "VCM accepts UserPromptSubmit and Stop hooks only."
351
+ hint: "VCM accepts UserPromptSubmit, Stop, StopFailure, and PostCompact hooks only."
242
352
  });
243
353
  }
244
354
  function throwUnsupportedEvent(eventName) {
@@ -34,6 +34,8 @@ const VCM_HOOK_DEFINITIONS = [
34
34
  { eventName: "PreToolUse", matcher: "Bash", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
35
35
  { eventName: "UserPromptSubmit", command: VCM_HOOK_COMMAND, timeout: 5 },
36
36
  { eventName: "Stop", command: VCM_STOP_HOOK_COMMAND, timeout: 10 },
37
+ { eventName: "StopFailure", command: VCM_HOOK_COMMAND, timeout: 5 },
38
+ { eventName: "PostCompact", command: VCM_HOOK_COMMAND, timeout: 5 },
37
39
  { eventName: "PermissionRequest", command: VCM_PERMISSION_REQUEST_HOOK_COMMAND, timeout: 5 }
38
40
  ];
39
41
  const HARNESS_FILES = [
@@ -228,16 +228,18 @@ export function createSessionService(deps) {
228
228
  return undefined;
229
229
  }
230
230
  const timestamp = now();
231
- const isStop = input.eventName === "Stop";
231
+ const isTurnEnd = isTurnEndHook(input.eventName);
232
+ const isCompact = isCompactHook(input.eventName);
232
233
  const updated = {
233
234
  ...current,
234
235
  claudeSessionId: input.sessionId ?? current.claudeSessionId,
235
236
  transcriptPath: input.transcriptPath ?? current.transcriptPath,
236
237
  cwd: input.cwd ?? current.cwd,
237
- activityStatus: isStop ? "idle" : "running",
238
+ activityStatus: isTurnEnd ? "idle" : isCompact ? current.activityStatus : "running",
238
239
  lastHookEventAt: timestamp,
239
- lastTurnEndedAt: isStop ? timestamp : current.lastTurnEndedAt,
240
- lastTurnStartedAt: isStop ? current.lastTurnStartedAt : timestamp,
240
+ lastTurnEndedAt: isTurnEnd ? timestamp : current.lastTurnEndedAt,
241
+ lastTurnStartedAt: isTurnEnd || isCompact ? current.lastTurnStartedAt : timestamp,
242
+ lastCompactAt: isCompact ? timestamp : current.lastCompactAt,
241
243
  updatedAt: timestamp
242
244
  };
243
245
  deps.registry.upsert(updated);
@@ -347,16 +349,18 @@ export function createSessionService(deps) {
347
349
  return undefined;
348
350
  }
349
351
  const timestamp = now();
350
- const isStop = input.eventName === "Stop";
352
+ const isTurnEnd = isTurnEndHook(input.eventName);
353
+ const isCompact = isCompactHook(input.eventName);
351
354
  const updated = {
352
355
  ...current,
353
356
  claudeSessionId: input.sessionId ?? current.claudeSessionId,
354
357
  transcriptPath: input.transcriptPath ?? current.transcriptPath,
355
358
  cwd: input.cwd ?? current.cwd,
356
- activityStatus: isStop ? "idle" : "running",
359
+ activityStatus: isTurnEnd ? "idle" : isCompact ? current.activityStatus : "running",
357
360
  lastHookEventAt: timestamp,
358
- lastTurnEndedAt: isStop ? timestamp : current.lastTurnEndedAt,
359
- lastTurnStartedAt: isStop ? current.lastTurnStartedAt : timestamp,
361
+ lastTurnEndedAt: isTurnEnd ? timestamp : current.lastTurnEndedAt,
362
+ lastTurnStartedAt: isTurnEnd || isCompact ? current.lastTurnStartedAt : timestamp,
363
+ lastCompactAt: isCompact ? timestamp : current.lastCompactAt,
360
364
  updatedAt: timestamp
361
365
  };
362
366
  deps.registry.upsert(updated);
@@ -521,6 +525,12 @@ function matchesRoleHookSession(record, input) {
521
525
  }
522
526
  return false;
523
527
  }
528
+ function isTurnEndHook(eventName) {
529
+ return eventName === "Stop" || eventName === "StopFailure";
530
+ }
531
+ function isCompactHook(eventName) {
532
+ return eventName === "PostCompact";
533
+ }
524
534
  function getRecoverableStatus(record) {
525
535
  if (!record.claudeSessionId) {
526
536
  return record.status === "running" ? "missing" : record.status;
@@ -29,13 +29,13 @@ Session = n x Round = m x Turn
29
29
 
30
30
  - `Session`: one VCM task. It is the statistics container for the whole task, from task creation until Close Task.
31
31
  - `Round`: one VCM conversation cycle. It starts with an accepted user prompt or VCM-delivered prompt, continues through sequential role orchestration, and ends when the final role turn stops and no next turn starts inside the 10 second stop window. In the normal orchestrated workflow, the final visible result comes back from `project-manager`.
32
- - `Turn`: one role-level Claude Code conversation. It starts when a prompt is submitted to one role session and ends when that role's Claude Code process emits `Stop`.
32
+ - `Turn`: one role-level Claude Code conversation. It starts when a prompt is submitted to one role session and ends when that role's Claude Code process emits `Stop` or `StopFailure`.
33
33
 
34
34
  Turns inside one Round are strictly sequential. VCM should finish one role Turn before starting the next role Turn in that Round.
35
35
 
36
36
  Session state is intentionally small: `created` means no Round has started yet, `running` means there is a current running Round, and `stopped` means there is no current running Round after at least one Round has stopped. Starting Claude Code role sessions does not make the VCM Session `running`.
37
37
 
38
- Round state is only `running` or `stopped`. After a `Stop` hook, the 10 second stop window still counts as `running`; the Round becomes `stopped` only when the timer expires without another `UserPromptSubmit`.
38
+ Round state is only `running` or `stopped`. After a turn-end hook, the 10 second stop window still counts as `running`; the Round becomes `stopped` only when the timer expires without another `UserPromptSubmit`.
39
39
 
40
40
  This `Session` term is only for VCM statistics. It must not be confused with a Claude Code role session, terminal runtime session, or Claude transcript session id. VCM still runs one Claude Code role session per role inside one task-level VCM Session.
41
41
 
@@ -395,6 +395,8 @@ Backend role state:
395
395
 
396
396
  - VCM terminal submit: role becomes `running`.
397
397
  - `Stop`: role becomes `idle` and records `lastTurnEndedAt`.
398
+ - `PostCompact`: refreshes role session metadata and records `lastCompactAt` without changing `running`/`idle`.
399
+ - `StopFailure`: first checks completion evidence. If the role already wrote an outgoing route file, VCM marks the role idle and dispatches normally. If not, VCM sends a recovery prompt to the same role without marking it idle.
398
400
  - The role tab and flow pause state both react to Claude Code and Codex Reviewer hook events.
399
401
 
400
402
  Task-level Round state:
@@ -402,14 +404,15 @@ Task-level Round state:
402
404
  - The first `UserPromptSubmit` starts a Round for the current VCM Session.
403
405
  - Each accepted `UserPromptSubmit` is the start of one Turn.
404
406
  - `Stop` ends the current Turn and starts a 10 second stop timer; during that timer, the Round is still `running`.
407
+ - `StopFailure` ends the Turn only after VCM decides the role completed or recovery is unavailable. When recovery is sent, the existing Turn stays running.
405
408
  - A new `UserPromptSubmit` inside the window continues the same Round and starts the next Turn.
406
409
  - If no new prompt is accepted before the deadline, the Round becomes `stopped`.
407
- - The stop transition is timer-driven from the `Stop` event. Round-state reads do not end a Round.
410
+ - The stop transition is timer-driven from the turn-end event. Round-state reads do not end a Round.
408
411
  - Before stopping, VCM checks `.ai/vcm/handoffs/messages`; if a pending route message exists and can be delivered, VCM retries delivery and extends the stop window instead of alerting.
409
- - The same Round state stores total Round count, Turn count, completed Turn count, and role active runtime. Active runtime is measured only between `UserPromptSubmit` and `Stop`, not during the stop window.
412
+ - The same Round state stores total Round count, Turn count, completed Turn count, and role active runtime. Active runtime is measured only between `UserPromptSubmit` and the turn-end event, not during the stop window.
410
413
  - The Round dock shows both wall-clock Round duration and role active runtime. For a running Round, `Total` is `now - Round.startedAt`; for a stopped Round, it is `stoppedAt - Round.startedAt`. `Role runtime` is the accumulated active runtime across Turns in that Round; `Turn count` is the number of accepted prompts in the Round.
411
414
 
412
- The frontend polls this task-level Round state and deduplicates each stopped Round so the same stopped state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `stoppedAt`, falling back to the last `Stop` when needed. Runs under 2 minutes trigger the weak 3-chime reminder at 1.4 second intervals. Runs at or above 2 minutes trigger the strong alert dialog and repeating sound until confirmation.
415
+ The frontend polls this task-level Round state and deduplicates each stopped Round so the same stopped state does not alert on every poll. Flow duration is measured from the first `UserPromptSubmit` to `stoppedAt`, falling back to the last turn-end timestamp when needed. Runs under 2 minutes trigger the weak 3-chime reminder at 1.4 second intervals. Runs at or above 2 minutes trigger the strong alert dialog and repeating sound until confirmation.
413
416
 
414
417
  ## 9. Session Lifecycle
415
418
 
@@ -542,9 +545,9 @@ Default route policy:
542
545
  - Peer routes such as `coder-reviewer.md` may exist for explicit task designs, but VCM still serializes delivery per target role.
543
546
  - `user` talks to `project-manager` through the normal active terminal or translation composer, not through a route file.
544
547
 
545
- Claude Stop-triggered scan:
548
+ Claude turn-end scan:
546
549
 
547
- - VCM injects Claude Code `UserPromptSubmit`, `Stop`, and `PermissionRequest` hooks into `.claude/settings.json`.
550
+ - VCM injects Claude Code `UserPromptSubmit`, `Stop`, `StopFailure`, `PostCompact`, and `PermissionRequest` hooks into `.claude/settings.json`.
548
551
  - The hooks do not call `vcmctl`; they POST directly to the local VCM backend.
549
552
  - When a Claude Code role stops, VCM marks that role idle, then scans pending route files.
550
553
  - VCM scans the stopped role's outgoing files and also any pending files targeting newly idle roles, so messages that were blocked by a busy target can be delivered after that target stops.
@@ -578,6 +581,8 @@ VCM Harness injects Claude Code hooks into `.claude/settings.json`:
578
581
 
579
582
  - `UserPromptSubmit`: posts directly to the VCM backend, marks the role running, and confirms any matching VCM message by recording `acceptedAt`
580
583
  - `Stop`: posts directly to the VCM backend, marks the role idle, and triggers pending route-file dispatch
584
+ - `StopFailure`: posts directly to the VCM backend; VCM checks for outgoing route-file completion evidence before marking idle. If no evidence exists, VCM sends a same-role recovery prompt and keeps the Turn running.
585
+ - `PostCompact`: posts directly to the VCM backend, refreshes `session_id`/`transcript_path`/`cwd`, and records `lastCompactAt`
581
586
  - `PermissionRequest`: posts directly to the VCM backend and prints the backend response to stdout so Claude Code can consume an allow decision when the local VCM setting is enabled
582
587
 
583
588
  VCM uses `UserPromptSubmit` as the Claude Code acceptance signal. A successful PTY write only proves VCM delivered text to the embedded terminal; `UserPromptSubmit` proves Claude Code accepted the prompt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-coding-master",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Local GUI session cockpit for Claude Code role sessions.",
5
5
  "type": "module",
6
6
  "files": [