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 +16 -12
- package/dist/backend/api/claude-hook-routes.js +5 -0
- package/dist/backend/api/message-routes.js +4 -0
- package/dist/backend/server.js +11 -3
- package/dist/backend/services/claude-hook-service.js +70 -0
- package/dist/backend/services/harness-service.js +95 -0
- package/dist/backend/services/message-service.js +76 -6
- package/dist/backend/services/round-service.js +17 -127
- package/dist/backend/services/session-service.js +39 -0
- package/dist/backend/services/status-service.js +0 -76
- package/dist/backend/templates/harness/architect-agent.js +4 -0
- package/dist/backend/templates/harness/claude-root.js +3 -0
- package/dist/backend/templates/harness/coder-agent.js +4 -0
- package/dist/backend/templates/harness/project-manager-agent.js +4 -0
- package/dist/backend/templates/harness/reviewer-agent.js +4 -0
- package/dist/backend/templates/message-envelope.js +3 -1
- package/dist/cli/vcmctl.js +30 -0
- package/dist/shared/types/claude-hook.js +1 -0
- package/dist-frontend/assets/{index-CyJrJge9.js → index-DVhkEVnA.js} +36 -35
- package/dist-frontend/assets/index-jEkUTnIY.css +32 -0
- package/dist-frontend/index.html +2 -2
- package/docs/cc-best-practices.md +8 -0
- package/docs/product-design.md +24 -25
- package/docs/v1-architecture-design.md +36 -36
- package/docs/v1-implementation-plan.md +44 -31
- package/package.json +1 -1
- package/dist-frontend/assets/index-N5DA0uE9.css +0 -32
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,
|
|
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
|
-
- `
|
|
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,
|
|
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
|
|
324
|
-
-
|
|
325
|
-
-
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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({
|
package/dist/backend/server.js
CHANGED
|
@@ -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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
131
|
+
const delivering = {
|
|
121
132
|
...message,
|
|
122
|
-
status: "
|
|
133
|
+
status: "delivering",
|
|
123
134
|
deliveredAt: timestamp,
|
|
124
135
|
queuedBehindMessageId: undefined,
|
|
125
136
|
failureReason: undefined
|
|
126
137
|
};
|
|
127
|
-
await submitTerminalInput(deps.runtime, sessionId, renderMessageEnvelope(
|
|
128
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
80
|
+
function toRoleTurnState(role, sessions) {
|
|
195
81
|
const session = sessions.find((candidate) => candidate.role === role && candidate.status === "running");
|
|
196
|
-
const
|
|
82
|
+
const activityStatus = session?.activityStatus ?? "idle";
|
|
197
83
|
return {
|
|
198
84
|
role,
|
|
199
85
|
sessionId: session?.id,
|
|
200
|
-
status:
|
|
201
|
-
pendingToolUseCount:
|
|
202
|
-
lastActivityAt:
|
|
203
|
-
lastAnswerEndedAt:
|
|
204
|
-
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) {
|