vibe-coding-master 0.7.17 → 0.7.18

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.
package/README.md CHANGED
@@ -282,7 +282,7 @@ not fall back to another model.
282
282
 
283
283
  ## Launch Template
284
284
 
285
- The launch template stores per-role defaults:
285
+ The global launch template stores per-role defaults for the five workflow roles:
286
286
 
287
287
  - permission mode
288
288
  - model
@@ -293,7 +293,12 @@ One-click start launches the four core roles. If any Gate Review Gate is enabled
293
293
  it also launches Gate Reviewer.
294
294
 
295
295
  Translator and Harness Engineer are tool roles. They are controlled from their
296
- own panels, not from the main role tab bar.
296
+ own panels, not from the main role tab bar or the one-click launch template.
297
+ Their permission, model, and effort are stored as independent tool Session
298
+ defaults only after an explicit Start or Restart succeeds. Selector changes,
299
+ Resume, and backend automatic startup do not write these defaults. Without a
300
+ saved value, both tools use `bypassPermissions`, the default model, and medium
301
+ effort. An existing task Session keeps its own recorded values.
297
302
 
298
303
  ## Orchestration
299
304
 
@@ -1,4 +1,5 @@
1
1
  import { isOpenFileLimitError, VcmError } from "../errors.js";
2
+ import { createDefaultToolSessionDefaults } from "../../shared/types/app-settings.js";
2
3
  export function registerHarnessRoutes(app, deps) {
3
4
  app.get("/api/projects/harness", async (request) => {
4
5
  const { task } = await requireHarnessTaskContext(deps, request.query.taskSlug);
@@ -68,13 +69,17 @@ export function registerHarnessRoutes(app, deps) {
68
69
  const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
69
70
  await deps.autoMemoryService.assertHarnessEngineerAvailable(task.worktreePath);
70
71
  await deps.harnessFeedbackService.assertHarnessEngineerAvailable(project.repoRoot);
71
- return deps.harnessService.startHarnessBootstrap(project.repoRoot, task.worktreePath, request.body ?? {});
72
+ const result = await deps.harnessService.startHarnessBootstrap(project.repoRoot, task.worktreePath, request.body ?? {});
73
+ await persistHarnessEngineerDefaults(deps.appSettings, result.session);
74
+ return result;
72
75
  });
73
76
  app.post("/api/projects/harness/bootstrap/restart", async (request) => {
74
77
  const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
75
78
  await deps.autoMemoryService.assertHarnessEngineerAvailable(task.worktreePath);
76
79
  await deps.harnessFeedbackService.assertHarnessEngineerAvailable(project.repoRoot);
77
- return deps.harnessService.restartHarnessBootstrap(project.repoRoot, task.worktreePath, request.body ?? {});
80
+ const result = await deps.harnessService.restartHarnessBootstrap(project.repoRoot, task.worktreePath, request.body ?? {});
81
+ await persistHarnessEngineerDefaults(deps.appSettings, result.session);
82
+ return result;
78
83
  });
79
84
  app.post("/api/projects/harness/bootstrap/stop", async (request) => {
80
85
  const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
@@ -111,7 +116,9 @@ export function registerHarnessRoutes(app, deps) {
111
116
  const taskSlug = requireTaskSlug(request.body?.taskSlug, "Harness Engineer");
112
117
  const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
113
118
  await deps.autoMemoryService.assertHarnessEngineerAvailable(task.worktreePath);
114
- return deps.sessionService.startRoleSession(project.repoRoot, taskSlug, "harness-engineer", request.body);
119
+ const session = await deps.sessionService.startRoleSession(project.repoRoot, taskSlug, "harness-engineer", request.body);
120
+ await persistHarnessEngineerDefaults(deps.appSettings, session);
121
+ return session;
115
122
  });
116
123
  app.post("/api/projects/harness/engineer/session/resume", async (request) => {
117
124
  const project = await requireCurrentProject(deps.projectService);
@@ -125,7 +132,9 @@ export function registerHarnessRoutes(app, deps) {
125
132
  const taskSlug = requireTaskSlug(request.body?.taskSlug, "Harness Engineer");
126
133
  const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
127
134
  await deps.autoMemoryService.assertHarnessEngineerAvailable(task.worktreePath);
128
- return deps.sessionService.restartRoleSession(project.repoRoot, taskSlug, "harness-engineer", request.body);
135
+ const session = await deps.sessionService.restartRoleSession(project.repoRoot, taskSlug, "harness-engineer", request.body);
136
+ await persistHarnessEngineerDefaults(deps.appSettings, session);
137
+ return session;
129
138
  });
130
139
  app.post("/api/projects/harness/engineer/session/stop", async (request) => {
131
140
  const project = await requireCurrentProject(deps.projectService);
@@ -201,6 +210,14 @@ export function registerHarnessRoutes(app, deps) {
201
210
  return deps.autoMemoryService.retryFailedReview(project.repoRoot, task.worktreePath);
202
211
  });
203
212
  }
213
+ async function persistHarnessEngineerDefaults(appSettings, session) {
214
+ const defaults = createDefaultToolSessionDefaults()["harness-engineer"];
215
+ await appSettings.updateToolSessionDefaults("harness-engineer", {
216
+ permissionMode: session.permissionMode ?? defaults.permissionMode,
217
+ model: session.model ?? defaults.model,
218
+ effort: session.effort ?? defaults.effort
219
+ });
220
+ }
204
221
  function requireTaskSlug(value, roleLabel) {
205
222
  const taskSlug = value?.trim();
206
223
  if (!taskSlug) {
@@ -1,4 +1,5 @@
1
1
  import { VcmError } from "../errors.js";
2
+ import { createDefaultToolSessionDefaults } from "../../shared/types/app-settings.js";
2
3
  export function registerTranslationWorkerRoutes(app, deps) {
3
4
  app.get("/api/translation/state", async () => {
4
5
  const project = await requireCurrentProject(deps.projectService);
@@ -29,7 +30,9 @@ export function registerTranslationWorkerRoutes(app, deps) {
29
30
  app.post("/api/translation/session/start", async (request) => {
30
31
  const project = await requireCurrentProject(deps.projectService);
31
32
  const taskSlug = requireTaskSlug(request.body?.taskSlug, "Translator");
32
- return deps.sessionService.startRoleSession(project.repoRoot, taskSlug, "translator", request.body);
33
+ const session = await deps.sessionService.startRoleSession(project.repoRoot, taskSlug, "translator", request.body);
34
+ await persistToolSessionDefaults(deps.appSettings, "translator", session);
35
+ return session;
33
36
  });
34
37
  app.post("/api/translation/session/resume", async (request) => {
35
38
  const project = await requireCurrentProject(deps.projectService);
@@ -44,7 +47,9 @@ export function registerTranslationWorkerRoutes(app, deps) {
44
47
  if (existing) {
45
48
  await deps.translationService.stopSession(existing.id, { clearCache: true });
46
49
  }
47
- return deps.sessionService.restartRoleSession(project.repoRoot, taskSlug, "translator", request.body);
50
+ const session = await deps.sessionService.restartRoleSession(project.repoRoot, taskSlug, "translator", request.body);
51
+ await persistToolSessionDefaults(deps.appSettings, "translator", session);
52
+ return session;
48
53
  });
49
54
  app.post("/api/translation/session/stop", async (request) => {
50
55
  const project = await requireCurrentProject(deps.projectService);
@@ -90,6 +95,14 @@ export function registerTranslationWorkerRoutes(app, deps) {
90
95
  return deps.translationWorkerService.promoteFileJob(project.repoRoot, request.params.jobId, targetPath);
91
96
  });
92
97
  }
98
+ async function persistToolSessionDefaults(appSettings, role, session) {
99
+ const defaults = createDefaultToolSessionDefaults().translator;
100
+ await appSettings.updateToolSessionDefaults(role, {
101
+ permissionMode: session.permissionMode,
102
+ model: session.model ?? defaults.model,
103
+ effort: session.effort ?? defaults.effort
104
+ });
105
+ }
93
106
  function requireTaskSlug(value, roleLabel) {
94
107
  const taskSlug = value?.trim();
95
108
  if (!taskSlug) {
@@ -42,9 +42,9 @@ const LEGACY_CODEX_HARNESS_PATHS = [
42
42
  ".claude/skills/vcm-codex-review-gate",
43
43
  ".ai/tools/request-codex-review"
44
44
  ];
45
- const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
46
- const VCM_STOP_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || true'`;
47
- const VCM_PERMISSION_REQUEST_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/permission-request" -H "content-type: application/json" --data-binary @- || true'`;
45
+ const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
46
+ const VCM_STOP_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || true'`;
47
+ const VCM_PERMISSION_REQUEST_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,runtimeSessionToken:process.env.VCM_RUNTIME_SESSION_TOKEN,event}));});'"'"' | curl -fsS --max-time 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/permission-request" -H "content-type: application/json" --data-binary @- || true'`;
48
48
  const VCM_BASH_GUARD_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ]; then exit 0; fi; guard=""; repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"; if [ -n "$repo" ] && [ -f "$repo/.ai/tools/vcm-bash-guard" ]; then guard="$repo/.ai/tools/vcm-bash-guard"; else cwd="$(pwd -P 2>/dev/null || pwd)"; dir="$cwd"; while [ -n "$dir" ] && [ "$dir" != "/" ]; do if [ -f "$dir/.ai/tools/vcm-bash-guard" ]; then guard="$dir/.ai/tools/vcm-bash-guard"; break; fi; dir="$(dirname "$dir")"; done; if [ -z "$guard" ] && [ -n "\${CLAUDE_PROJECT_DIR:-}" ] && [ -f "\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard" ]; then guard="\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard"; fi; fi; [ -n "$guard" ] || exit 0; python3 "$guard" || exit 0'`;
49
49
  const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
50
50
  const VCM_AUTO_MEMORY_ENABLED = false;
@@ -93,6 +93,7 @@ export async function createServer(deps, options = {}) {
93
93
  gateReviewService: deps.gateReviewService
94
94
  });
95
95
  registerTranslationWorkerRoutes(app, {
96
+ appSettings: deps.appSettings,
96
97
  projectService: deps.projectService,
97
98
  translationWorkerService: deps.translationWorkerService,
98
99
  sessionService: deps.sessionService,
@@ -103,6 +104,7 @@ export async function createServer(deps, options = {}) {
103
104
  runtimeRecoveryService: deps.runtimeRecoveryService
104
105
  });
105
106
  registerHarnessRoutes(app, {
107
+ appSettings: deps.appSettings,
106
108
  projectService: deps.projectService,
107
109
  harnessService: deps.harnessService,
108
110
  harnessFeedbackService: deps.harnessFeedbackService,
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import { createHash } from "node:crypto";
3
3
  import { VCM_ROLE_NAMES } from "../../shared/constants.js";
4
4
  import { GATE_REVIEW_GATES } from "../../shared/types/gate-review.js";
5
- import { createDefaultLaunchTemplate, DEFAULT_TRANSLATION_OUTPUT_MODE, DEFAULT_TRANSLATION_TARGET_LANGUAGE, TRANSLATION_OUTPUT_MODE_OPTIONS, TRANSLATION_TARGET_LANGUAGE_OPTIONS } from "../../shared/types/app-settings.js";
5
+ import { createDefaultLaunchTemplate, createDefaultToolSessionDefaults, DEFAULT_TRANSLATION_OUTPUT_MODE, DEFAULT_TRANSLATION_TARGET_LANGUAGE, TRANSLATION_OUTPUT_MODE_OPTIONS, TRANSLATION_TARGET_LANGUAGE_OPTIONS } from "../../shared/types/app-settings.js";
6
6
  import { CLAUDE_MODEL_OPTIONS, CCR_GPT_SESSION_MODEL, SESSION_EFFORT_OPTIONS } from "../../shared/types/session.js";
7
7
  import { resolveVcmDataDir } from "../vcm-data-dir.js";
8
8
  const MAX_RECENT_REPOSITORIES = 5;
@@ -73,6 +73,21 @@ export function createAppSettingsService(deps) {
73
73
  });
74
74
  return preferences;
75
75
  },
76
+ async updateToolSessionDefaults(role, input) {
77
+ const current = await loadSettings();
78
+ const preferences = normalizePreferences({
79
+ ...current.preferences,
80
+ toolSessionDefaults: {
81
+ ...current.preferences.toolSessionDefaults,
82
+ [role]: input
83
+ }
84
+ });
85
+ await saveSettings({
86
+ ...current,
87
+ preferences
88
+ });
89
+ return preferences.toolSessionDefaults[role];
90
+ },
76
91
  async getRecentRepositoryPaths() {
77
92
  return (await loadSettings()).recentRepositoryPaths;
78
93
  },
@@ -274,7 +289,8 @@ function normalizePreferences(input) {
274
289
  translationAutoSendEnabled: candidate.translationAutoSendEnabled === true,
275
290
  translationTargetLanguage: normalizeTranslationTargetLanguage(candidate.translationTargetLanguage),
276
291
  translationOutputMode: normalizeTranslationOutputMode(candidate.translationOutputMode),
277
- launchTemplate: normalizeLaunchTemplate(candidate.launchTemplate)
292
+ launchTemplate: normalizeLaunchTemplate(candidate.launchTemplate),
293
+ toolSessionDefaults: normalizeToolSessionDefaults(candidate.toolSessionDefaults)
278
294
  };
279
295
  }
280
296
  function normalizeThemeMode(input) {
@@ -313,6 +329,14 @@ function normalizeLaunchTemplate(input) {
313
329
  autoOrchestration: input.autoOrchestration !== false
314
330
  };
315
331
  }
332
+ function normalizeToolSessionDefaults(input) {
333
+ const defaults = createDefaultToolSessionDefaults();
334
+ const candidate = isObject(input) ? input : {};
335
+ return {
336
+ translator: normalizeRoleLaunchTemplateEntry(candidate.translator, defaults.translator),
337
+ "harness-engineer": normalizeRoleLaunchTemplateEntry(candidate["harness-engineer"], defaults["harness-engineer"])
338
+ };
339
+ }
316
340
  function normalizeRoleLaunchTemplateEntry(input, fallback) {
317
341
  const candidate = isObject(input) ? input : {};
318
342
  return {
@@ -2,6 +2,7 @@ import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorTool
2
2
  import { VcmError } from "../errors.js";
3
3
  import { readTranscriptTurnEvidence } from "./claude-transcript-reply.js";
4
4
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
5
+ import { matchesRoleHookSession } from "./session-service.js";
5
6
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
6
7
  const MAX_ROLE_RETRY_ATTEMPTS = 20;
7
8
  const ROLE_RETRY_BASE_DELAY_MS = 60_000;
@@ -18,6 +19,7 @@ const NON_RETRYABLE_STOP_FAILURE_ERRORS = new Set([
18
19
  const DIAGNOSTIC_SNIPPET_MAX_LENGTH = 2000;
19
20
  export function createClaudeHookService(deps) {
20
21
  const stopFailureRetryTimers = new Map();
22
+ const roleHookLocks = new Map();
21
23
  const now = deps.now ?? (() => new Date().toISOString());
22
24
  const retrySetTimeout = deps.retrySetTimeout ?? ((callback, delayMs) => globalThis.setTimeout(callback, delayMs));
23
25
  const retryClearTimeout = deps.retryClearTimeout ?? ((timer) => globalThis.clearTimeout(timer));
@@ -79,7 +81,9 @@ export function createClaudeHookService(deps) {
79
81
  eventName,
80
82
  sessionId: stringOrUndefined(input.event.session_id),
81
83
  transcriptPath: stringOrUndefined(input.event.transcript_path),
82
- cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
84
+ cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
85
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
86
+ runtimeSessionToken: input.runtimeSessionToken
83
87
  })
84
88
  : await deps.sessionService.recordRoleHookEvent(context.project.repoRoot, {
85
89
  taskSlug: input.taskSlug,
@@ -88,9 +92,12 @@ export function createClaudeHookService(deps) {
88
92
  sessionId: stringOrUndefined(input.event.session_id),
89
93
  transcriptPath: stringOrUndefined(input.event.transcript_path),
90
94
  cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
91
- allowSessionMismatch: true
95
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
96
+ runtimeSessionToken: input.runtimeSessionToken
92
97
  });
93
- await deps.translationWorkerService?.handleTranslatorHook(context.project.repoRoot, eventName, input.taskSlug);
98
+ if (session) {
99
+ await deps.translationWorkerService?.handleTranslatorHook(context.project.repoRoot, eventName, input.taskSlug);
100
+ }
94
101
  return {
95
102
  ok: true,
96
103
  eventName,
@@ -109,7 +116,9 @@ export function createClaudeHookService(deps) {
109
116
  eventName,
110
117
  sessionId: stringOrUndefined(input.event.session_id),
111
118
  transcriptPath: stringOrUndefined(input.event.transcript_path),
112
- cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
119
+ cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
120
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
121
+ runtimeSessionToken: input.runtimeSessionToken
113
122
  })
114
123
  : await deps.sessionService.recordRoleHookEvent(context.project.repoRoot, {
115
124
  taskSlug: input.taskSlug,
@@ -118,8 +127,12 @@ export function createClaudeHookService(deps) {
118
127
  sessionId: stringOrUndefined(input.event.session_id),
119
128
  transcriptPath: stringOrUndefined(input.event.transcript_path),
120
129
  cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
121
- allowSessionMismatch: true
130
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
131
+ runtimeSessionToken: input.runtimeSessionToken
122
132
  });
133
+ if (!session) {
134
+ return completedHookResult(input, eventName);
135
+ }
123
136
  const activeTask = deps.autoMemoryService
124
137
  ? (await deps.taskService.listTasks(context.project.repoRoot))
125
138
  .find((task) => task.cleanupStatus !== "cleaned" && (projectScoped || task.taskSlug === input.taskSlug))
@@ -176,27 +189,33 @@ export function createClaudeHookService(deps) {
176
189
  throwUnsupportedEvent(eventName);
177
190
  }
178
191
  const context = await getHookContext(input);
192
+ if (!(await isCurrentRoleHook(context, input, eventName))) {
193
+ return completedHookResult(input, eventName);
194
+ }
179
195
  const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
180
196
  if (memoryResult) {
181
197
  return memoryResult;
182
198
  }
183
- const boundToTask = await isHookSessionBoundToTask(context, input.role);
184
- if (boundToTask) {
185
- deps.jobGuard?.notePromptSubmitted({
186
- repoRoot: context.project.repoRoot,
187
- taskSlug: context.taskSlug,
188
- role: input.role
189
- });
190
- }
191
199
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
192
200
  taskSlug: context.taskSlug,
193
201
  role: input.role,
194
202
  eventName,
195
203
  claudeSessionId: stringOrUndefined(input.event.session_id),
196
204
  transcriptPath: stringOrUndefined(input.event.transcript_path),
197
- cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
205
+ cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
206
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
207
+ runtimeSessionToken: input.runtimeSessionToken
198
208
  });
209
+ if (!session) {
210
+ return completedHookResult(input, eventName);
211
+ }
212
+ const boundToTask = await isHookSessionBoundToTask(context, input.role);
199
213
  if (boundToTask) {
214
+ deps.jobGuard?.notePromptSubmitted({
215
+ repoRoot: context.project.repoRoot,
216
+ taskSlug: context.taskSlug,
217
+ role: input.role
218
+ });
200
219
  await deps.roundService.recordClaudeHookEvent({
201
220
  repoRoot: context.project.repoRoot,
202
221
  stateRepoRoot: context.taskRepoRoot,
@@ -246,6 +265,9 @@ export function createClaudeHookService(deps) {
246
265
  throwUnsupportedEvent(eventName);
247
266
  }
248
267
  const context = await getHookContext(input);
268
+ if (!(await isCurrentRoleHook(context, input, eventName))) {
269
+ return completedHookResult(input, eventName);
270
+ }
249
271
  const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
250
272
  if (memoryResult) {
251
273
  return memoryResult;
@@ -312,6 +334,9 @@ export function createClaudeHookService(deps) {
312
334
  throwUnsupportedEvent(eventName);
313
335
  }
314
336
  const context = await getHookContext(input);
337
+ if (!(await isCurrentRoleHook(context, input, eventName))) {
338
+ return completedHookResult(input, eventName);
339
+ }
315
340
  const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
316
341
  if (memoryResult) {
317
342
  return memoryResult;
@@ -372,6 +397,9 @@ export function createClaudeHookService(deps) {
372
397
  throwUnsupportedEvent(eventName);
373
398
  }
374
399
  const context = await getHookContext(input);
400
+ if (!(await isCurrentRoleHook(context, input, eventName))) {
401
+ return completedHookResult(input, eventName);
402
+ }
375
403
  const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
376
404
  if (memoryResult) {
377
405
  return memoryResult;
@@ -382,7 +410,9 @@ export function createClaudeHookService(deps) {
382
410
  eventName,
383
411
  claudeSessionId: stringOrUndefined(input.event.session_id),
384
412
  transcriptPath: stringOrUndefined(input.event.transcript_path),
385
- cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
413
+ cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
414
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
415
+ runtimeSessionToken: input.runtimeSessionToken
386
416
  });
387
417
  return {
388
418
  ok: true,
@@ -396,15 +426,20 @@ export function createClaudeHookService(deps) {
396
426
  async function recordTurnEnd(input, context, eventName, options) {
397
427
  const scopedRouteDispatchInput = createRouteDispatchInput(input, context, input.role);
398
428
  const settleRouteDispatchInput = createRouteDispatchInput(input, context);
399
- const boundToTask = await isHookSessionBoundToTask(context, input.role);
400
429
  const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
401
430
  taskSlug: context.taskSlug,
402
431
  role: input.role,
403
432
  eventName,
404
433
  claudeSessionId: stringOrUndefined(input.event.session_id),
405
434
  transcriptPath: stringOrUndefined(input.event.transcript_path),
406
- cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
435
+ cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
436
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
437
+ runtimeSessionToken: input.runtimeSessionToken
407
438
  });
439
+ if (!session) {
440
+ return completedHookResult(input, eventName);
441
+ }
442
+ const boundToTask = await isHookSessionBoundToTask(context, input.role);
408
443
  if (boundToTask) {
409
444
  await deps.roundService.recordClaudeHookEvent({
410
445
  repoRoot: context.project.repoRoot,
@@ -483,8 +518,13 @@ export function createClaudeHookService(deps) {
483
518
  eventName,
484
519
  claudeSessionId: stringOrUndefined(input.event.session_id),
485
520
  transcriptPath: stringOrUndefined(input.event.transcript_path),
486
- cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
521
+ cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd),
522
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
523
+ runtimeSessionToken: input.runtimeSessionToken
487
524
  });
525
+ if (!session) {
526
+ return completedHookResult(input, eventName);
527
+ }
488
528
  const boundToTask = await isHookSessionBoundToTask(context, input.role);
489
529
  if (boundToTask && eventName !== "PostCompact") {
490
530
  await deps.roundService.recordClaudeHookEvent({
@@ -523,6 +563,31 @@ export function createClaudeHookService(deps) {
523
563
  ...(stoppedRole ? { stoppedRole } : {})
524
564
  };
525
565
  }
566
+ async function isCurrentRoleHook(context, input, eventName) {
567
+ const current = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
568
+ return Boolean(current && matchesRoleHookSession(current, {
569
+ eventName,
570
+ sessionId: stringOrUndefined(input.event.session_id),
571
+ transcriptPath: stringOrUndefined(input.event.transcript_path),
572
+ runtimeSessionId: stringOrUndefined(input.event.vcm_runtime_session_id),
573
+ runtimeSessionToken: input.runtimeSessionToken
574
+ }));
575
+ }
576
+ async function withRoleHookLock(input, run) {
577
+ const key = `${input.taskSlug}:${input.role}`;
578
+ const previous = roleHookLocks.get(key) ?? Promise.resolve();
579
+ const result = previous.catch(() => undefined).then(run);
580
+ const tail = result.then(() => undefined, () => undefined);
581
+ roleHookLocks.set(key, tail);
582
+ try {
583
+ return await result;
584
+ }
585
+ finally {
586
+ if (roleHookLocks.get(key) === tail) {
587
+ roleHookLocks.delete(key);
588
+ }
589
+ }
590
+ }
526
591
  async function scheduleStopFailureRetry(input, context, failure) {
527
592
  const preferences = await deps.appSettings.getPreferences();
528
593
  if (!preferences.roleRetryEnabled || !deps.runtime) {
@@ -562,7 +627,10 @@ export function createClaudeHookService(deps) {
562
627
  nextRetryAt
563
628
  }
564
629
  });
565
- await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
630
+ const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
631
+ if (session) {
632
+ await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role, session.id);
633
+ }
566
634
  scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
567
635
  return "scheduled";
568
636
  }
@@ -630,7 +698,7 @@ export function createClaudeHookService(deps) {
630
698
  }
631
699
  });
632
700
  await submitTerminalInput(deps.runtime, session.id, renderStopFailureRecoveryPrompt());
633
- await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
701
+ await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role, session.id);
634
702
  }
635
703
  async function markStopFailureRecoveryFailed(input, context, failure, attempt, timestamp = now()) {
636
704
  clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
@@ -736,44 +804,50 @@ export function createClaudeHookService(deps) {
736
804
  }
737
805
  return {
738
806
  async handleHook(input) {
739
- if (isTranslatorToolRoleName(input.role)) {
740
- return processTranslatorHook(input);
741
- }
742
- if (isHarnessEngineerToolRoleName(input.role)) {
743
- return processHarnessEngineerHook(input);
744
- }
745
- const eventName = parseHookEvent(input.event.hook_event_name);
746
- if (eventName === "UserPromptSubmit") {
747
- return handleUserPromptSubmitHook(input);
748
- }
749
- if (eventName === "StopFailure") {
750
- return processStopFailureHook(input);
751
- }
752
- if (eventName === "PostCompact") {
753
- return processPostCompactHook(input);
754
- }
755
- // Legacy combined endpoint: the installed hook discards the response,
756
- // so a block decision could not be enforced. Never block here.
757
- return processStopHook(input, { allowBlock: false });
807
+ return withRoleHookLock(input, async () => {
808
+ if (isTranslatorToolRoleName(input.role)) {
809
+ return processTranslatorHook(input);
810
+ }
811
+ if (isHarnessEngineerToolRoleName(input.role)) {
812
+ return processHarnessEngineerHook(input);
813
+ }
814
+ const eventName = parseHookEvent(input.event.hook_event_name);
815
+ if (eventName === "UserPromptSubmit") {
816
+ return handleUserPromptSubmitHook(input);
817
+ }
818
+ if (eventName === "StopFailure") {
819
+ return processStopFailureHook(input);
820
+ }
821
+ if (eventName === "PostCompact") {
822
+ return processPostCompactHook(input);
823
+ }
824
+ // Legacy combined endpoint: the installed hook discards the response,
825
+ // so a block decision could not be enforced. Never block here.
826
+ return processStopHook(input, { allowBlock: false });
827
+ });
758
828
  },
759
829
  handleStopHook(input) {
760
- if (isTranslatorToolRoleName(input.role)) {
761
- return processTranslatorHook(input);
762
- }
763
- if (isHarnessEngineerToolRoleName(input.role)) {
764
- return processHarnessEngineerHook(input);
765
- }
766
- return processStopHook(input, { allowBlock: true });
830
+ return withRoleHookLock(input, async () => {
831
+ if (isTranslatorToolRoleName(input.role)) {
832
+ return processTranslatorHook(input);
833
+ }
834
+ if (isHarnessEngineerToolRoleName(input.role)) {
835
+ return processHarnessEngineerHook(input);
836
+ }
837
+ return processStopHook(input, { allowBlock: true });
838
+ });
767
839
  },
768
840
  handleReconciledTurnEnd(input) {
769
- const eventName = parseHookEvent(input.event.hook_event_name);
770
- if (eventName === "Stop") {
771
- return processStopHook(input, { allowBlock: false });
772
- }
773
- if (eventName === "StopFailure") {
774
- return processStopFailureHook(input);
775
- }
776
- throwUnsupportedEvent(eventName);
841
+ return withRoleHookLock(input, async () => {
842
+ const eventName = parseHookEvent(input.event.hook_event_name);
843
+ if (eventName === "Stop") {
844
+ return processStopHook(input, { allowBlock: false });
845
+ }
846
+ if (eventName === "StopFailure") {
847
+ return processStopFailureHook(input);
848
+ }
849
+ throwUnsupportedEvent(eventName);
850
+ });
777
851
  },
778
852
  handlePermissionRequestHook
779
853
  };