vibe-coding-master 0.0.13 → 0.0.14

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
@@ -14,7 +14,7 @@ Each role runs as a real Claude Code process inside an embedded terminal. The GU
14
14
  ## Current V1 Capabilities
15
15
 
16
16
  - GUI-first task workspace.
17
- - Collapsible sidebar with repository connection, workflow, settings, harness status, task creation, and task list.
17
+ - Collapsible sidebar with repository connection, settings, harness status, task creation, and task list.
18
18
  - Recent repository path dropdown, stored locally with the five most recent paths.
19
19
  - Embedded Claude Code terminals powered by `node-pty` and `xterm.js`.
20
20
  - One Claude Code session per role, with role tabs in the task header.
@@ -156,8 +156,6 @@ project-manager
156
156
  -> project-manager final acceptance, commit, and PR
157
157
  ```
158
158
 
159
- The workflow status is shown in the sidebar `Workflow` section. It is a soft guide in V1: VCM highlights missing or incomplete handoff artifacts and suggests the next step, but it does not hard-block the user from manually starting or switching roles.
160
-
161
159
  ## Task Worktree Management
162
160
 
163
161
  VCM uses task-level worktree management by default:
@@ -189,8 +187,7 @@ The left sidebar is intentionally compact and collapsible:
189
187
 
190
188
  - `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
191
189
  - `Repository`: connected path, branch, and working tree state. `Working tree: uncommitted changes` means `git status --porcelain` is not empty.
192
- - `Workflow`: current soft gate and five workflow steps.
193
- - `Settings`: `Theme`, `Round alert`, `Messages`, and `Events`.
190
+ - `Settings`: `Theme`, `Round alert`, `Try alert`, `Messages`, and `Events`.
194
191
  - `VCM Harness`: status for `CLAUDE.md`, role agent files, and `.gitignore`.
195
192
  - `New Task`: one `task name` input.
196
193
  - `Tasks`: task list and task status.
@@ -211,7 +208,7 @@ The same file stores recent repository paths. The translation API key is stored
211
208
 
212
209
  The sidebar `Settings` section also stores the UI theme preference in this file. The default is `system`, which follows the OS/browser color-scheme preference; users can cycle between `System`, `Light`, and `Dark`.
213
210
 
214
- The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus short sound that fires when VCM detects that the current full conversation round is complete.
211
+ The same sidebar also has a `Round alert` toggle. It is on by default and controls the in-app prompt plus short sound that fires when VCM detects that the current full conversation round is complete. The `Try alert` button triggers the same local prompt and sound for testing.
215
212
 
216
213
  Translation behavior:
217
214
 
@@ -263,6 +260,8 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
263
260
 
264
261
  `.ai/vcm/` is the active VCM local control area, and `.claude/worktrees/` is the Claude-compatible task worktree area. The base repo keeps the task index; each task runtime repo keeps its own session, message, orchestration, and translation state.
265
262
 
263
+ VCM also JSON-merges `.claude/settings.json` to install Claude Code `UserPromptSubmit` and `Stop` hooks. Those hooks call `vcmctl hook-event` through the session-provided `VCM_CTL_COMMAND`, so users do not need to configure hook commands manually.
264
+
266
265
  After applying harness changes, VCM reports the exact files changed and reminds the user to review and commit them before starting long-running work.
267
266
 
268
267
  Role sessions learn VCM rules from `CLAUDE.md` and `.claude/agents/*.md`. VCM does not paste a long context block into the terminal at session start.
@@ -311,7 +310,8 @@ When it is off, VCM is in manual mode:
311
310
  - Roles may send messages through `vcmctl`.
312
311
  - Messages appear in the `Messages` modal.
313
312
  - The user can inspect them.
314
- - The current GUI shows sequence, timestamp, status, body preview, path, and a `Copy` button for each message.
313
+ - The current GUI shows sequence, timestamp, status, body preview, path, `Copy`, and `Mark All Done`.
314
+ - `Mark All Done` marks open message records as `acknowledged` after the user manually copied or handled stuck queued/in-flight messages.
315
315
  - The user decides what to do next by copying or manually acting on the message.
316
316
  - VCM does not write to the target terminal or press Enter for the user.
317
317
 
@@ -320,11 +320,15 @@ When it is on, VCM is in auto mode:
320
320
  - Backend policy still applies.
321
321
  - PM can send work to `architect`, `coder`, or `reviewer`.
322
322
  - Non-PM roles can reply only to `project-manager`.
323
- - If the target role session is running and has no active delivered message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it.
324
- - When the GUI observes a newly delivered auto message, it switches the active role tab to that message's target role so the user can watch the next step.
325
- - VCM enforces per-role turn-taking: each target role can have at most one in-flight delivered message.
323
+ - If the target role session is running and has no active message, VCM writes a `[VCM MESSAGE]` envelope to the target terminal and submits it as `delivering`.
324
+ - Claude Code `UserPromptSubmit` confirms the prompt really entered the target role session; VCM then marks the message `submitted`.
325
+ - When the GUI observes a newly submitted auto message, it switches the active role tab to that message's target role so the user can watch the next step.
326
+ - VCM enforces per-role turn-taking: each target role can have at most one in-flight `delivering`, `submitted`, `delivered`, or `staged` message.
326
327
  - Additional messages to a busy target role are kept as `queued` and are not written to that terminal.
327
328
  - When a role sends `vcmctl reply` or `vcmctl result`, VCM acknowledges that role's active message and delivers the next queued message for that role.
329
+ - If auto orchestration gets stuck after a manual copy/paste recovery, `Mark All Done` clears open `pending_approval`, `queued`, `staged`, `delivering`, `submitted`, `delivered`, and failed message records to `acknowledged`.
330
+
331
+ VCM Harness injects Claude Code `UserPromptSubmit` and `Stop` hooks into `.claude/settings.json`. Role tabs show `running` after `UserPromptSubmit` and `idle` after `Stop`; the terminal process status is still tracked separately. The injected role rules also require a role to end its turn after any successful `vcmctl send`, `vcmctl reply`, or `vcmctl result`, rather than running `vcmctl inbox`, looping, polling, or waiting for another role inside the same Claude Code turn.
328
332
 
329
333
  The backend state model still contains a `paused` field for compatibility with existing API routes, but the current GUI exposes only a single on/off orchestration toggle.
330
334
 
@@ -332,9 +336,9 @@ The backend still exposes stage/approve/reject compatibility APIs for automation
332
336
 
333
337
  ## Round Completion Alerts
334
338
 
335
- VCM detects conversation completion from Claude Code transcript JSONL events, not PTY silence. The backend watches each running role session and treats `stop_reason === "end_turn"` as a candidate end only after pending tool uses have finished and a short debounce passes.
339
+ VCM detects conversation completion from the hook-driven role activity state, not PTY silence. `UserPromptSubmit` marks a role `running`, and `Stop` marks that role `idle` with a stop timestamp.
336
340
 
337
- For role chains, VCM waits for the latest delivered message's target role. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM finishes the final response. If no VCM role message is involved, the latest direct role answer can complete the round.
341
+ For role chains, VCM waits for the latest active message's target role to reach hook `Stop`. For example, if PM sends work to Coder and Coder sends a result back to PM, the round is not complete when Coder finishes; it is complete only after PM reaches `Stop` for the final response. If no VCM role message is involved, the latest direct role `Stop` can complete the round.
338
342
 
339
343
  When `Round alert` is enabled, the frontend polls the task round state, deduplicates each completion id, shows a small `Round complete` prompt, and plays a short local sound.
340
344
 
@@ -0,0 +1,5 @@
1
+ export function registerClaudeHookRoutes(app, deps) {
2
+ app.post("/api/hooks/claude-code", async (request) => {
3
+ return deps.claudeHookService.handleHook(request.body);
4
+ });
5
+ }
@@ -12,6 +12,10 @@ export function registerMessageRoutes(app, deps) {
12
12
  ...request.body
13
13
  });
14
14
  });
15
+ app.post("/api/tasks/:taskSlug/messages/mark-all-done", async (request) => {
16
+ const context = await getRouteContext(deps, request.params.taskSlug);
17
+ return deps.messageService.markAllDone(context);
18
+ });
15
19
  app.post("/api/tasks/:taskSlug/messages/:messageId/stage", async (request) => {
16
20
  const context = await getRouteContext(deps, request.params.taskSlug);
17
21
  return deps.messageService.stageMessage({
@@ -7,6 +7,7 @@ import { createArtifactService } from "./services/artifact-service.js";
7
7
  import { createClaudeAdapter } from "./adapters/claude-adapter.js";
8
8
  import { createCommandRunner } from "./adapters/command-runner.js";
9
9
  import { createCommandDispatcher } from "./services/command-dispatcher.js";
10
+ import { createClaudeHookService } from "./services/claude-hook-service.js";
10
11
  import { createGitAdapter } from "./adapters/git-adapter.js";
11
12
  import { createAppSettingsService } from "./services/app-settings-service.js";
12
13
  import { createClaudeTranscriptService } from "./services/claude-transcript-service.js";
@@ -24,6 +25,7 @@ import { createTaskService } from "./services/task-service.js";
24
25
  import { createTranslationService } from "./services/translation-service.js";
25
26
  import { registerAppSettingsRoutes } from "./api/app-settings-routes.js";
26
27
  import { registerArtifactRoutes } from "./api/artifact-routes.js";
28
+ import { registerClaudeHookRoutes } from "./api/claude-hook-routes.js";
27
29
  import { registerHarnessRoutes } from "./api/harness-routes.js";
28
30
  import { registerMessageRoutes } from "./api/message-routes.js";
29
31
  import { registerProjectRoutes } from "./api/project-routes.js";
@@ -48,6 +50,7 @@ export async function createServer(deps, options = {}) {
48
50
  });
49
51
  });
50
52
  registerAppSettingsRoutes(app, { appSettings: deps.appSettings });
53
+ registerClaudeHookRoutes(app, { claudeHookService: deps.claudeHookService });
51
54
  registerProjectRoutes(app, { projectService: deps.projectService });
52
55
  registerHarnessRoutes(app, {
53
56
  projectService: deps.projectService,
@@ -157,10 +160,14 @@ export function createDefaultServerDeps(options = {}) {
157
160
  sessionService,
158
161
  taskService
159
162
  });
160
- const transcripts = createClaudeTranscriptService();
161
- const roundService = createRoundService({
162
- transcripts
163
+ const claudeHookService = createClaudeHookService({
164
+ projectService,
165
+ taskService,
166
+ sessionService,
167
+ messageService
163
168
  });
169
+ const transcripts = createClaudeTranscriptService();
170
+ const roundService = createRoundService();
164
171
  const translationService = createTranslationService({
165
172
  runtime,
166
173
  sessionRegistry: registry,
@@ -179,6 +186,7 @@ export function createDefaultServerDeps(options = {}) {
179
186
  artifactService,
180
187
  harnessService,
181
188
  commandDispatcher,
189
+ claudeHookService,
182
190
  messageService,
183
191
  roundService,
184
192
  statusService,
@@ -0,0 +1,70 @@
1
+ import { isRoleName } from "../../shared/constants.js";
2
+ import { VcmError } from "../errors.js";
3
+ import { getTaskRuntimeRepoRoot } from "./task-service.js";
4
+ export function createClaudeHookService(deps) {
5
+ return {
6
+ async handleHook(input) {
7
+ if (!isRoleName(input.role)) {
8
+ throw new VcmError({
9
+ code: "HOOK_ROLE_INVALID",
10
+ message: `Unknown hook role: ${input.role}`,
11
+ statusCode: 400
12
+ });
13
+ }
14
+ const eventName = parseHookEventName(input.event.hook_event_name);
15
+ const project = await deps.projectService.getCurrentProject();
16
+ if (!project) {
17
+ throw new VcmError({
18
+ code: "PROJECT_NOT_CONNECTED",
19
+ message: "Connect a repository before accepting Claude Code hooks.",
20
+ statusCode: 409
21
+ });
22
+ }
23
+ const session = await deps.sessionService.recordClaudeHookEvent(project.repoRoot, {
24
+ taskSlug: input.taskSlug,
25
+ role: input.role,
26
+ eventName,
27
+ claudeSessionId: stringOrUndefined(input.event.session_id),
28
+ transcriptPath: stringOrUndefined(input.event.transcript_path),
29
+ cwd: stringOrUndefined(input.event.cwd)
30
+ });
31
+ let submittedMessageId;
32
+ if (eventName === "UserPromptSubmit" && typeof input.event.prompt === "string") {
33
+ const config = await deps.projectService.loadConfig(project.repoRoot);
34
+ const task = await deps.taskService.loadTask(project.repoRoot, input.taskSlug);
35
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
36
+ const submitted = await deps.messageService.confirmPromptSubmitted({
37
+ repoRoot: project.repoRoot,
38
+ stateRepoRoot: taskRepoRoot,
39
+ stateRoot: config.stateRoot,
40
+ taskSlug: input.taskSlug,
41
+ role: input.role,
42
+ prompt: input.event.prompt
43
+ });
44
+ submittedMessageId = submitted?.id;
45
+ }
46
+ return {
47
+ ok: true,
48
+ eventName,
49
+ taskSlug: input.taskSlug,
50
+ role: input.role,
51
+ sessionUpdated: Boolean(session),
52
+ submittedMessageId
53
+ };
54
+ }
55
+ };
56
+ }
57
+ function parseHookEventName(value) {
58
+ if (value === "UserPromptSubmit" || value === "Stop") {
59
+ return value;
60
+ }
61
+ throw new VcmError({
62
+ code: "HOOK_EVENT_UNSUPPORTED",
63
+ message: `Unsupported Claude Code hook event: ${String(value)}`,
64
+ statusCode: 400,
65
+ hint: "VCM currently accepts UserPromptSubmit and Stop hooks only."
66
+ });
67
+ }
68
+ function stringOrUndefined(value) {
69
+ return typeof value === "string" ? value : undefined;
70
+ }
@@ -8,6 +8,9 @@ import { renderReviewerHarnessRules } from "../templates/harness/reviewer-agent.
8
8
  export const VCM_HARNESS_VERSION = 1;
9
9
  const MANAGED_BLOCK_PATTERN = /<!-- VCM:BEGIN(?:\s+version=(\d+))? -->[\s\S]*?<!-- VCM:END -->/m;
10
10
  const HASH_MANAGED_BLOCK_PATTERN = /# VCM:BEGIN(?:\s+version=(\d+))?\n[\s\S]*?# VCM:END/m;
11
+ const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
12
+ const VCM_HOOK_COMMAND = "sh -c 'if [ -n \"${VCM_CTL_COMMAND:-}\" ]; then eval \"$VCM_CTL_COMMAND hook-event\"; else vcmctl hook-event; fi'";
13
+ const VCM_HOOK_EVENTS = ["UserPromptSubmit", "Stop"];
11
14
  const HARNESS_FILES = [
12
15
  {
13
16
  kind: "root-claude",
@@ -84,6 +87,7 @@ async function analyzeHarnessFiles(fs, repoRoot) {
84
87
  for (const definition of HARNESS_FILES) {
85
88
  analyses.push(await analyzeHarnessFile(fs, repoRoot, definition));
86
89
  }
90
+ analyses.push(await analyzeClaudeSettingsFile(fs, repoRoot));
87
91
  return analyses;
88
92
  }
89
93
  async function analyzeHarnessFile(fs, repoRoot, definition) {
@@ -188,6 +192,97 @@ function renderNewHarnessFile(definition, block) {
188
192
  : "";
189
193
  return `${frontmatter}# ${definition.title}\n\n${block}\n`;
190
194
  }
195
+ async function analyzeClaudeSettingsFile(fs, repoRoot) {
196
+ const definition = {
197
+ kind: "claude-settings",
198
+ path: CLAUDE_SETTINGS_PATH,
199
+ title: "Claude Code Settings",
200
+ renderRules: () => ""
201
+ };
202
+ const absolutePath = resolveHarnessPath(repoRoot, CLAUDE_SETTINGS_PATH);
203
+ const exists = await fs.pathExists(absolutePath);
204
+ const current = exists
205
+ ? parseJsonObject(await fs.readText(absolutePath))
206
+ : {};
207
+ const next = withVcmClaudeHooks(current);
208
+ const currentContent = exists
209
+ ? `${JSON.stringify(current, null, 2)}\n`
210
+ : "";
211
+ const nextContent = `${JSON.stringify(next, null, 2)}\n`;
212
+ const action = exists
213
+ ? currentContent === nextContent ? "ok" : "update"
214
+ : "create";
215
+ return {
216
+ definition,
217
+ status: {
218
+ kind: "claude-settings",
219
+ path: CLAUDE_SETTINGS_PATH,
220
+ exists,
221
+ hasManagedBlock: false,
222
+ action
223
+ },
224
+ plannedChange: action === "ok"
225
+ ? undefined
226
+ : {
227
+ path: CLAUDE_SETTINGS_PATH,
228
+ action,
229
+ reason: exists
230
+ ? "Claude Code hook settings do not contain the VCM UserPromptSubmit/Stop hook bridge."
231
+ : "Claude Code hook settings are missing; VCM will create them."
232
+ },
233
+ nextContent: action === "ok" ? undefined : nextContent
234
+ };
235
+ }
236
+ function parseJsonObject(content) {
237
+ const parsed = JSON.parse(content);
238
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
239
+ return {};
240
+ }
241
+ return parsed;
242
+ }
243
+ function withVcmClaudeHooks(settings) {
244
+ const hooks = isPlainObject(settings.hooks)
245
+ ? { ...settings.hooks }
246
+ : {};
247
+ for (const eventName of VCM_HOOK_EVENTS) {
248
+ const existingMatchers = Array.isArray(hooks[eventName])
249
+ ? hooks[eventName]
250
+ : [];
251
+ hooks[eventName] = [
252
+ ...existingMatchers.filter((entry) => !isVcmHookMatcher(entry)),
253
+ createVcmHookMatcher()
254
+ ];
255
+ }
256
+ return {
257
+ ...settings,
258
+ hooks
259
+ };
260
+ }
261
+ function createVcmHookMatcher() {
262
+ return {
263
+ hooks: [
264
+ {
265
+ type: "command",
266
+ command: VCM_HOOK_COMMAND,
267
+ timeout: 5
268
+ }
269
+ ]
270
+ };
271
+ }
272
+ function isVcmHookMatcher(value) {
273
+ if (!isPlainObject(value) || !Array.isArray(value.hooks)) {
274
+ return false;
275
+ }
276
+ return value.hooks.some((hook) => {
277
+ if (!isPlainObject(hook)) {
278
+ return false;
279
+ }
280
+ return typeof hook.command === "string" && hook.command.includes("hook-event");
281
+ });
282
+ }
283
+ function isPlainObject(value) {
284
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
285
+ }
191
286
  function renderAgentFrontmatter(name, description) {
192
287
  return `---\nname: ${name}\ndescription: ${description}\ntools: Read, Grep, Glob, Bash, Edit, Write\n---`;
193
288
  }
@@ -8,6 +8,17 @@ import { renderManualStagePrompt, renderMessageEnvelope } from "../templates/mes
8
8
  const PM_ROLE = "project-manager";
9
9
  const PM_TO_ROLE_TYPES = new Set(["task", "question", "review-request", "revise", "cancel"]);
10
10
  const ROLE_TO_PM_TYPES = new Set(["result", "question", "blocked", "finding"]);
11
+ const MARK_ALL_DONE_STATUSES = new Set([
12
+ "pending_approval",
13
+ "queued",
14
+ "staged",
15
+ "delivering",
16
+ "submitted",
17
+ "delivered",
18
+ "failed",
19
+ "delivery_failed",
20
+ "response_ready_missing_result"
21
+ ]);
11
22
  export function createMessageService(deps) {
12
23
  const now = deps.now ?? (() => new Date().toISOString());
13
24
  const id = deps.id ?? (() => `msg_${randomUUID()}`);
@@ -117,15 +128,15 @@ export function createMessageService(deps) {
117
128
  return delivered;
118
129
  }
119
130
  async function deliverMessageToSession(sessionId, message, timestamp) {
120
- const delivered = {
131
+ const delivering = {
121
132
  ...message,
122
- status: "delivered",
133
+ status: "delivering",
123
134
  deliveredAt: timestamp,
124
135
  queuedBehindMessageId: undefined,
125
136
  failureReason: undefined
126
137
  };
127
- await submitTerminalInput(deps.runtime, sessionId, renderMessageEnvelope(delivered));
128
- return delivered;
138
+ await submitTerminalInput(deps.runtime, sessionId, renderMessageEnvelope(delivering));
139
+ return delivering;
129
140
  }
130
141
  return {
131
142
  listMessages(input) {
@@ -134,6 +145,55 @@ export function createMessageService(deps) {
134
145
  async sendMessage(input) {
135
146
  return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), () => sendMessageLocked(input));
136
147
  },
148
+ async confirmPromptSubmitted(input) {
149
+ const messageId = extractSubmittedMessageId(input.prompt);
150
+ if (!messageId) {
151
+ return undefined;
152
+ }
153
+ const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
154
+ const message = messages.find((candidate) => candidate.id === messageId && candidate.toRole === input.role);
155
+ if (!message) {
156
+ return undefined;
157
+ }
158
+ if (message.status === "submitted" || message.status === "acknowledged") {
159
+ return message;
160
+ }
161
+ if (!isSubmittableMessageStatus(message.status)) {
162
+ return undefined;
163
+ }
164
+ const submitted = {
165
+ ...message,
166
+ status: "submitted",
167
+ submittedAt: now(),
168
+ failureReason: undefined
169
+ };
170
+ await appendMessageSnapshot(deps.fs, input, submitted);
171
+ return submitted;
172
+ },
173
+ async markAllDone(input) {
174
+ return withTaskLock(taskLocks, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), async () => {
175
+ const timestamp = now();
176
+ const messages = await readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
177
+ const updated = messages
178
+ .filter((message) => MARK_ALL_DONE_STATUSES.has(message.status))
179
+ .map((message) => ({
180
+ ...message,
181
+ status: "acknowledged",
182
+ acknowledgedAt: timestamp,
183
+ queuedBehindMessageId: undefined,
184
+ failureReason: undefined
185
+ }));
186
+ for (const message of updated) {
187
+ await appendMessageSnapshot(deps.fs, input, message);
188
+ }
189
+ const latest = applyMessageSnapshots(messages, updated);
190
+ return {
191
+ taskSlug: input.taskSlug,
192
+ updatedCount: updated.length,
193
+ messages: latest
194
+ };
195
+ });
196
+ },
137
197
  async stageMessage(input) {
138
198
  const message = await getMessageOrThrow(deps.fs, input);
139
199
  const session = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, message.toRole);
@@ -239,11 +299,11 @@ function isRoleActor(actor) {
239
299
  return ROLE_NAMES.includes(actor);
240
300
  }
241
301
  function findActiveMessageForRole(messages, role) {
242
- return messages.find((message) => message.toRole === role && message.status === "delivered");
302
+ return messages.find((message) => message.toRole === role && isActiveMessageStatus(message.status));
243
303
  }
244
304
  function acknowledgeActiveMessages(messages, role, timestamp) {
245
305
  return messages
246
- .filter((message) => message.toRole === role && message.status === "delivered")
306
+ .filter((message) => message.toRole === role && isActiveMessageStatus(message.status))
247
307
  .map((message) => ({
248
308
  ...message,
249
309
  status: "acknowledged",
@@ -251,6 +311,16 @@ function acknowledgeActiveMessages(messages, role, timestamp) {
251
311
  failureReason: undefined
252
312
  }));
253
313
  }
314
+ function isActiveMessageStatus(status) {
315
+ return status === "delivering" || status === "submitted" || status === "delivered" || status === "staged";
316
+ }
317
+ function isSubmittableMessageStatus(status) {
318
+ return status === "delivering" || status === "delivered" || status === "staged" || status === "pending_approval";
319
+ }
320
+ function extractSubmittedMessageId(prompt) {
321
+ const match = prompt.match(/\bmsg_[A-Za-z0-9_-]+\b/);
322
+ return match?.[0];
323
+ }
254
324
  function applyMessageSnapshots(messages, snapshots) {
255
325
  if (snapshots.length === 0) {
256
326
  return messages;
@@ -1,117 +1,9 @@
1
1
  import { ROLE_NAMES } from "../../shared/constants.js";
2
- export function createRoundService(deps) {
2
+ export function createRoundService(deps = {}) {
3
3
  const now = deps.now ?? (() => new Date().toISOString());
4
- const debounceMs = deps.debounceMs ?? 1500;
5
- const trackers = new Map();
6
- function ensureTracker(session) {
7
- if (session.status !== "running") {
8
- stopSession(session.id);
9
- return;
10
- }
11
- if (trackers.has(session.id)) {
12
- return;
13
- }
14
- const tracker = {
15
- role: session.role,
16
- sessionId: session.id,
17
- taskSlug: session.taskSlug,
18
- status: "unknown",
19
- pendingToolUseIds: new Set(),
20
- unsubscribe: () => { }
21
- };
22
- tracker.unsubscribe = deps.transcripts.subscribeToRoleSession(session, (event) => {
23
- handleTranscriptEvent(tracker, event);
24
- }, {
25
- onError(error) {
26
- tracker.status = "unknown";
27
- tracker.reason = error.message;
28
- }
29
- });
30
- trackers.set(session.id, tracker);
31
- }
32
- function handleTranscriptEvent(tracker, event) {
33
- tracker.lastActivityAt = event.timestamp;
34
- clearIdleTimer(tracker);
35
- if (event.kind === "tool_use") {
36
- tracker.pendingToolUseIds.add(event.id);
37
- tracker.status = "using_tools";
38
- tracker.reason = undefined;
39
- return;
40
- }
41
- if (event.kind === "tool_result") {
42
- tracker.pendingToolUseIds.delete(event.toolResult.tool_use_id);
43
- tracker.status = tracker.pendingToolUseIds.size > 0 ? "using_tools" : "answering";
44
- tracker.reason = undefined;
45
- return;
46
- }
47
- if (event.kind === "question") {
48
- tracker.status = "waiting_user";
49
- tracker.reason = "Claude Code is asking for user input.";
50
- return;
51
- }
52
- if (event.kind === "agent" || event.kind === "todo") {
53
- tracker.status = "answering";
54
- tracker.reason = undefined;
55
- return;
56
- }
57
- if (event.kind === "thinking") {
58
- tracker.status = "answering";
59
- tracker.reason = undefined;
60
- return;
61
- }
62
- if (event.kind === "text") {
63
- if (event.stopReason === "max_tokens" || event.stopReason === "stop_sequence" || event.stopReason === "refusal") {
64
- tracker.status = "abnormal";
65
- tracker.reason = `Claude Code stopped with ${event.stopReason}.`;
66
- return;
67
- }
68
- tracker.status = "answering";
69
- tracker.reason = undefined;
70
- if (event.stopReason === "end_turn" && tracker.pendingToolUseIds.size === 0 && event.text.trim()) {
71
- scheduleIdle(tracker, event.timestamp);
72
- }
73
- }
74
- }
75
- function scheduleIdle(tracker, endedAt) {
76
- tracker.pendingAnswerEndedAt = endedAt;
77
- tracker.idleTimer = setTimeout(() => {
78
- if (tracker.pendingToolUseIds.size > 0 || tracker.pendingAnswerEndedAt !== endedAt) {
79
- return;
80
- }
81
- tracker.status = "idle";
82
- tracker.lastAnswerEndedAt = endedAt;
83
- tracker.reason = undefined;
84
- tracker.idleTimer = undefined;
85
- }, debounceMs);
86
- }
87
- function clearIdleTimer(tracker) {
88
- tracker.pendingAnswerEndedAt = undefined;
89
- if (tracker.idleTimer) {
90
- clearTimeout(tracker.idleTimer);
91
- tracker.idleTimer = undefined;
92
- }
93
- }
94
- function stopSession(sessionId) {
95
- const tracker = trackers.get(sessionId);
96
- if (!tracker) {
97
- return;
98
- }
99
- clearIdleTimer(tracker);
100
- tracker.unsubscribe();
101
- trackers.delete(sessionId);
102
- }
103
4
  return {
104
5
  getTaskRoundState(input) {
105
- const liveSessionIds = new Set(input.sessions.filter((session) => session.status === "running").map((session) => session.id));
106
- for (const session of input.sessions) {
107
- ensureTracker(session);
108
- }
109
- for (const [sessionId, tracker] of trackers) {
110
- if (tracker.taskSlug === input.taskSlug && !liveSessionIds.has(sessionId)) {
111
- stopSession(sessionId);
112
- }
113
- }
114
- const roleStates = ROLE_NAMES.map((role) => toRoleTurnState(role, input.sessions, trackers));
6
+ const roleStates = ROLE_NAMES.map((role) => toRoleTurnState(role, input.sessions));
115
7
  const latestDelivered = getLatestDeliveredMessage(input.messages);
116
8
  const queuedMessageCount = input.messages.filter((message) => message.status === "queued").length;
117
9
  const pendingMessageCount = input.messages.filter((message) => message.status === "pending_approval").length;
@@ -129,14 +21,8 @@ export function createRoundService(deps) {
129
21
  pendingMessageCount
130
22
  };
131
23
  },
132
- stopSession,
133
- stopTask(taskSlug) {
134
- for (const [sessionId, tracker] of trackers) {
135
- if (tracker.taskSlug === taskSlug) {
136
- stopSession(sessionId);
137
- }
138
- }
139
- }
24
+ stopSession() { },
25
+ stopTask() { }
140
26
  };
141
27
  }
142
28
  export function evaluateTaskRoundState(input) {
@@ -191,27 +77,31 @@ export function evaluateTaskRoundState(input) {
191
77
  updatedAt: input.updatedAt
192
78
  };
193
79
  }
194
- function toRoleTurnState(role, sessions, trackers) {
80
+ function toRoleTurnState(role, sessions) {
195
81
  const session = sessions.find((candidate) => candidate.role === role && candidate.status === "running");
196
- const tracker = session ? trackers.get(session.id) : undefined;
82
+ const activityStatus = session?.activityStatus ?? "idle";
197
83
  return {
198
84
  role,
199
85
  sessionId: session?.id,
200
- status: tracker?.status ?? "unknown",
201
- pendingToolUseCount: tracker?.pendingToolUseIds.size ?? 0,
202
- lastActivityAt: tracker?.lastActivityAt,
203
- lastAnswerEndedAt: tracker?.lastAnswerEndedAt,
204
- reason: tracker?.reason
86
+ status: session ? activityStatus === "running" ? "answering" : "idle" : "unknown",
87
+ pendingToolUseCount: 0,
88
+ lastActivityAt: session?.lastPromptSubmittedAt ?? session?.lastHookEventAt,
89
+ lastAnswerEndedAt: session?.lastStopAt,
90
+ reason: session
91
+ ? activityStatus === "running"
92
+ ? "Claude Code accepted a prompt and has not emitted Stop yet."
93
+ : "Claude Code emitted Stop or has not started a prompt in this process."
94
+ : undefined
205
95
  };
206
96
  }
207
97
  function getLatestDeliveredMessage(messages) {
208
98
  return messages
209
- .filter((message) => message.status === "delivered" || message.status === "staged")
99
+ .filter((message) => message.status === "delivering" || message.status === "submitted" || message.status === "delivered" || message.status === "staged")
210
100
  .sort((left, right) => getMessageDeliveredAt(left).localeCompare(getMessageDeliveredAt(right)))
211
101
  .at(-1);
212
102
  }
213
103
  function getMessageDeliveredAt(message) {
214
- return message?.deliveredAt ?? message?.stagedAt ?? message?.createdAt ?? "";
104
+ return message?.submittedAt ?? message?.deliveredAt ?? message?.stagedAt ?? message?.createdAt ?? "";
215
105
  }
216
106
  function getCompletedAtForDelivery(roleState, deliveredAt) {
217
107
  if (roleState.status !== "idle" || !roleState.lastAnswerEndedAt) {