vibe-coding-master 0.0.9 → 0.0.11
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 +38 -25
- package/dist/backend/api/app-settings-routes.js +8 -0
- package/dist/backend/api/message-routes.js +3 -1
- package/dist/backend/api/session-routes.js +7 -1
- package/dist/backend/api/task-routes.js +12 -10
- package/dist/backend/api/translation-routes.js +21 -3
- package/dist/backend/runtime/terminal-submit.js +20 -0
- package/dist/backend/server.js +9 -4
- package/dist/backend/services/app-settings-service.js +28 -0
- package/dist/backend/services/claude-transcript-service.js +12 -8
- package/dist/backend/services/command-dispatcher.js +2 -1
- package/dist/backend/services/message-service.js +10 -6
- package/dist/backend/services/project-service.js +0 -3
- package/dist/backend/services/session-service.js +7 -4
- package/dist/backend/services/task-service.js +65 -56
- package/dist/backend/services/translation-service.js +264 -77
- package/dist/shared/types/app-settings.js +1 -0
- package/dist-frontend/assets/index-Bi4X3GSR.css +32 -0
- package/dist-frontend/assets/index-DaHXq14j.js +88 -0
- package/dist-frontend/index.html +2 -2
- package/docs/cc-best-practices.md +4 -4
- package/docs/product-design.md +71 -33
- package/docs/v1-architecture-design.md +92 -60
- package/docs/v1-implementation-plan.md +101 -61
- package/package.json +3 -1
- package/dist/backend/ws/translation-ws.js +0 -35
- package/dist-frontend/assets/index-CuiNNOzj.css +0 -32
- package/dist-frontend/assets/index-D59GuHCR.js +0 -58
package/README.md
CHANGED
|
@@ -160,13 +160,13 @@ The workflow status is shown in the sidebar `Workflow` section. It is a soft gui
|
|
|
160
160
|
|
|
161
161
|
## Task Worktree Management
|
|
162
162
|
|
|
163
|
-
VCM uses task-level worktree management:
|
|
163
|
+
VCM uses task-level worktree management by default:
|
|
164
164
|
|
|
165
165
|
```text
|
|
166
166
|
one task = one branch + one git worktree + one handoff directory + one role-session set
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
-
The default when creating a task:
|
|
169
|
+
The `Create worktree and branch` option is selected by default when creating a task:
|
|
170
170
|
|
|
171
171
|
- task name: `<task>`
|
|
172
172
|
- branch: `feature/<task>`
|
|
@@ -175,11 +175,13 @@ The default when creating a task:
|
|
|
175
175
|
|
|
176
176
|
VCM will not create worktrees per role. `project-manager`, `architect`, `coder`, and `reviewer` for the same task share the same task worktree.
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
The user can turn this option off. In that mode, VCM creates the task metadata and handoff structure in the connected repository, records the current branch, and starts role sessions from the connected repository path.
|
|
179
|
+
|
|
180
|
+
VCM will not offer a separate `Create task worktree` button after a task exists, and a task should not be switched to another branch/worktree mode after creation.
|
|
179
181
|
|
|
180
182
|
Because worktrees live under `.ai/vcm/worktrees/`, the connected repository must ignore `.ai/vcm/`. Apply the VCM Harness before creating tasks so `.gitignore` contains the managed ignore block. The base repository must also be clean because the task branch/worktree is created from the connected repo's current `HEAD`.
|
|
181
183
|
|
|
182
|
-
When a task is complete, VCM provides a
|
|
184
|
+
When a task is complete, VCM provides a red `Close Task` action. Closing a task shows a destructive confirmation, stops VCM-managed running role sessions for that task, then deletes the task worktree, deletes the task branch by default, removes the base task index entry, and removes task runtime metadata. VCM does not preflight running sessions or uncommitted changes before closing. Tasks created without a worktree only remove VCM metadata because they do not own a separate branch/worktree.
|
|
183
185
|
|
|
184
186
|
## Sidebar UI
|
|
185
187
|
|
|
@@ -188,7 +190,7 @@ The left sidebar is intentionally compact and collapsible:
|
|
|
188
190
|
- `Repository Path`: path input on one row; `Recent` and `Connect` on the next row.
|
|
189
191
|
- `Repository`: connected path, branch, and working tree state. `Working tree: uncommitted changes` means `git status --porcelain` is not empty.
|
|
190
192
|
- `Workflow`: current soft gate and five workflow steps.
|
|
191
|
-
- `Settings`: `Messages`, `Events`, and the `Auto orchestration` on/off toggle.
|
|
193
|
+
- `Settings`: `Theme`, `Messages`, `Events`, and the `Auto orchestration` on/off toggle.
|
|
192
194
|
- `VCM Harness`: status for `CLAUDE.md`, role agent files, and `.gitignore`.
|
|
193
195
|
- `New Task`: one `task name` input.
|
|
194
196
|
- `Tasks`: task list and task status.
|
|
@@ -207,17 +209,24 @@ Translation settings are local and stored in:
|
|
|
207
209
|
|
|
208
210
|
The same file stores recent repository paths. The translation API key is stored locally under `translation.secrets.apiKey`; it is not written to the connected repository, `.ai/handoffs`, raw terminal logs, or git diffs.
|
|
209
211
|
|
|
212
|
+
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
|
Translation behavior:
|
|
211
215
|
|
|
212
216
|
- Provider type is OpenAI-compatible chat completions.
|
|
213
217
|
- Prompt slots are `zh-to-en`, `zh-to-en-with-context`, and `en-to-zh`.
|
|
214
|
-
- The settings modal shows
|
|
218
|
+
- The settings modal shows all three prompt slots as direct editors and includes `Reset prompts` to restore the built-in defaults.
|
|
215
219
|
- Claude Code output translation reads semantic Claude transcript JSONL files under `~/.claude/projects`, not raw PTY output.
|
|
220
|
+
- VCM tails those transcript files in the backend. Closing the translation panel does not stop capture; the tailer stops only when the role session is stopped/restarted or the task is closed.
|
|
221
|
+
- Translation events are cached under the task runtime repo at `.ai/vcm/translation/<task>/<role>/<session-id>.jsonl` and delivered to the frontend through HTTP polling.
|
|
222
|
+
- The polling cursor is the next expected seq: `after=18` acknowledges seq `1..17` and returns seq `18+`; there is no snapshot mismatch error.
|
|
216
223
|
- Assistant prose is shown as English source while translating, then replaced by the translated Chinese result.
|
|
224
|
+
- Assistant prose renders Markdown in the panel, including headings, lists, code fences, tables, and links.
|
|
217
225
|
- Tool calls and tool results are preserved as dim one-line rows such as `● Bash({"command":"npm test"})`.
|
|
218
226
|
- User input uses one textarea. Press `Enter` to translate or send the current English draft; press `Shift+Enter` for a newline.
|
|
219
227
|
- After user input is translated, the English draft replaces the original text in the same textarea.
|
|
220
228
|
- `Send English` writes the current English draft to the active embedded terminal and submits it.
|
|
229
|
+
- Automatic terminal submission uses bracketed paste first, then sends Enter separately for Claude Code TUI reliability.
|
|
221
230
|
- The translation panel `Auto-send` toggle sends the translated draft automatically when translation succeeds without warnings.
|
|
222
231
|
|
|
223
232
|
## Project Harness
|
|
@@ -249,7 +258,7 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
|
|
|
249
258
|
# VCM:END
|
|
250
259
|
```
|
|
251
260
|
|
|
252
|
-
`.ai/vcm/` is the active VCM local control area
|
|
261
|
+
`.ai/vcm/` is the active VCM local control area. The base repo keeps the task index and nested worktrees; each task runtime repo keeps its own session, message, orchestration, and translation state.
|
|
253
262
|
|
|
254
263
|
After applying harness changes, VCM reports the exact files changed and reminds the user to review and commit them before starting long-running work.
|
|
255
264
|
|
|
@@ -281,8 +290,8 @@ vcmctl inbox
|
|
|
281
290
|
Durable message and handoff files:
|
|
282
291
|
|
|
283
292
|
```text
|
|
284
|
-
.ai/vcm/messages/<task>.jsonl
|
|
285
|
-
.ai/vcm/orchestration/<task>.json
|
|
293
|
+
.ai/vcm/messages/<task>.jsonl # under the task runtime repo
|
|
294
|
+
.ai/vcm/orchestration/<task>.json # under the task runtime repo
|
|
286
295
|
.ai/handoffs/<task>/messages/<message-id>.md
|
|
287
296
|
.ai/handoffs/<task>/role-commands/
|
|
288
297
|
.ai/handoffs/<task>/logs/
|
|
@@ -299,8 +308,9 @@ When it is off, VCM is in manual mode:
|
|
|
299
308
|
- Roles may send messages through `vcmctl`.
|
|
300
309
|
- Messages appear in the `Messages` modal.
|
|
301
310
|
- The user can inspect them.
|
|
302
|
-
-
|
|
303
|
-
-
|
|
311
|
+
- The current GUI shows sequence, timestamp, status, body preview, path, and a `Copy` button for each message.
|
|
312
|
+
- The user decides what to do next by copying or manually acting on the message.
|
|
313
|
+
- VCM does not write to the target terminal or press Enter for the user.
|
|
304
314
|
|
|
305
315
|
When it is on, VCM is in auto mode:
|
|
306
316
|
|
|
@@ -311,12 +321,14 @@ When it is on, VCM is in auto mode:
|
|
|
311
321
|
|
|
312
322
|
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.
|
|
313
323
|
|
|
324
|
+
The backend still exposes stage/approve/reject compatibility APIs for automation and future UI work. They are not primary controls in the current Messages modal.
|
|
325
|
+
|
|
314
326
|
## Resume Behavior
|
|
315
327
|
|
|
316
328
|
Each role session stores its Claude session id and transcript path under:
|
|
317
329
|
|
|
318
330
|
```text
|
|
319
|
-
.ai/vcm/sessions/<task>.json
|
|
331
|
+
.ai/vcm/sessions/<task>.json # under the task runtime repo
|
|
320
332
|
```
|
|
321
333
|
|
|
322
334
|
Session buttons behave as follows:
|
|
@@ -332,21 +344,22 @@ For a connected repository, VCM uses:
|
|
|
332
344
|
|
|
333
345
|
```text
|
|
334
346
|
~/.vcm/projects/<project-id>/config.json
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
+
<baseRepoRoot>/.ai/vcm/tasks/<task>.json
|
|
348
|
+
<baseRepoRoot>/.ai/vcm/worktrees/<task>/
|
|
349
|
+
<taskRepoRoot>/.ai/vcm/sessions/<task>.json
|
|
350
|
+
<taskRepoRoot>/.ai/vcm/messages/<task>.jsonl
|
|
351
|
+
<taskRepoRoot>/.ai/vcm/orchestration/<task>.json
|
|
352
|
+
<taskRepoRoot>/.ai/vcm/translation/<task>/
|
|
353
|
+
<taskRepoRoot>/.ai/handoffs/<task>/architecture-plan.md
|
|
354
|
+
<taskRepoRoot>/.ai/handoffs/<task>/implementation-log.md
|
|
355
|
+
<taskRepoRoot>/.ai/handoffs/<task>/validation-log.md
|
|
356
|
+
<taskRepoRoot>/.ai/handoffs/<task>/review-report.md
|
|
357
|
+
<taskRepoRoot>/.ai/handoffs/<task>/docs-sync-report.md
|
|
358
|
+
<taskRepoRoot>/.ai/handoffs/<task>/role-commands/{architect,coder,reviewer}.md
|
|
359
|
+
<taskRepoRoot>/.ai/handoffs/<task>/logs/{project-manager,architect,coder,reviewer}.log
|
|
347
360
|
```
|
|
348
361
|
|
|
349
|
-
The project config is stored under `~/.vcm` so it is durable local app state and is not hidden inside a Git-ignored repository directory.
|
|
362
|
+
The project config is stored under `~/.vcm` so it is durable local app state and is not hidden inside a Git-ignored repository directory. For worktree-backed tasks, `taskRepoRoot` is `<baseRepoRoot>/.ai/vcm/worktrees/<task>`; for inline tasks, `taskRepoRoot` is the connected base repo.
|
|
350
363
|
|
|
351
364
|
## Packaging
|
|
352
365
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export function registerAppSettingsRoutes(app, deps) {
|
|
2
|
+
app.get("/api/settings/preferences", async () => {
|
|
3
|
+
return deps.appSettings.getPreferences();
|
|
4
|
+
});
|
|
5
|
+
app.put("/api/settings/preferences", async (request) => {
|
|
6
|
+
return deps.appSettings.updatePreferences(request.body ?? {});
|
|
7
|
+
});
|
|
8
|
+
}
|
|
@@ -71,9 +71,11 @@ async function getRouteContext(deps, taskSlug) {
|
|
|
71
71
|
const project = await requireCurrentProject(deps.projectService);
|
|
72
72
|
const config = await deps.projectService.loadConfig(project.repoRoot);
|
|
73
73
|
const task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
|
|
74
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
74
75
|
return {
|
|
75
76
|
repoRoot: project.repoRoot,
|
|
76
|
-
taskRepoRoot
|
|
77
|
+
taskRepoRoot,
|
|
78
|
+
stateRepoRoot: taskRepoRoot,
|
|
77
79
|
stateRoot: config.stateRoot,
|
|
78
80
|
handoffDir: task.handoffDir,
|
|
79
81
|
taskSlug
|
|
@@ -13,11 +13,17 @@ export function registerSessionRoutes(app, deps) {
|
|
|
13
13
|
app.post("/api/tasks/:taskSlug/sessions/:role/stop", async (request) => {
|
|
14
14
|
const project = await requireCurrentProject(deps.projectService);
|
|
15
15
|
const role = parseRole(request.params.role);
|
|
16
|
-
|
|
16
|
+
const session = await deps.sessionService.stopRoleSession(project.repoRoot, request.params.taskSlug, role);
|
|
17
|
+
await deps.translationService.stopSession(session.id);
|
|
18
|
+
return session;
|
|
17
19
|
});
|
|
18
20
|
app.post("/api/tasks/:taskSlug/sessions/:role/restart", async (request) => {
|
|
19
21
|
const project = await requireCurrentProject(deps.projectService);
|
|
20
22
|
const role = parseRole(request.params.role);
|
|
23
|
+
const existing = await deps.sessionService.getRoleSession(project.repoRoot, request.params.taskSlug, role);
|
|
24
|
+
if (existing) {
|
|
25
|
+
await deps.translationService.stopSession(existing.id, { clearCache: true });
|
|
26
|
+
}
|
|
21
27
|
return deps.sessionService.restartRoleSession(project.repoRoot, request.params.taskSlug, role, request.body);
|
|
22
28
|
});
|
|
23
29
|
app.post("/api/tasks/:taskSlug/sessions/:role/resume", async (request) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VcmError } from "../errors.js";
|
|
2
|
+
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
2
3
|
export function registerTaskRoutes(app, deps) {
|
|
3
4
|
app.get("/api/tasks", async () => {
|
|
4
5
|
const project = await requireCurrentProject(deps.projectService);
|
|
@@ -18,19 +19,20 @@ export function registerTaskRoutes(app, deps) {
|
|
|
18
19
|
});
|
|
19
20
|
app.post("/api/tasks/:taskSlug/cleanup", async (request) => {
|
|
20
21
|
const project = await requireCurrentProject(deps.projectService);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
throw new VcmError({
|
|
25
|
-
code: "TASK_SESSIONS_RUNNING",
|
|
26
|
-
message: "Stop all role sessions before cleaning up the task.",
|
|
27
|
-
statusCode: 409,
|
|
28
|
-
hint: running.map((session) => session.role).join(", ")
|
|
29
|
-
});
|
|
30
|
-
}
|
|
22
|
+
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
23
|
+
await stopRunningRoleSessions(deps, project.repoRoot, request.params.taskSlug);
|
|
24
|
+
await deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), request.params.taskSlug, { clearCache: true });
|
|
31
25
|
return deps.taskService.cleanupTask(project.repoRoot, request.params.taskSlug, request.body ?? {});
|
|
32
26
|
});
|
|
33
27
|
}
|
|
28
|
+
async function stopRunningRoleSessions(deps, repoRoot, taskSlug) {
|
|
29
|
+
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
30
|
+
for (const session of sessions) {
|
|
31
|
+
if (session.status === "running") {
|
|
32
|
+
await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
34
36
|
async function requireCurrentProject(projectService) {
|
|
35
37
|
const project = await projectService.getCurrentProject();
|
|
36
38
|
if (!project) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isRoleName } from "../../shared/constants.js";
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
|
+
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
3
4
|
export function registerTranslationRoutes(app, deps) {
|
|
4
5
|
app.get("/api/translation/settings", async () => {
|
|
5
6
|
return deps.translationService.getSettings();
|
|
@@ -14,12 +15,28 @@ export function registerTranslationRoutes(app, deps) {
|
|
|
14
15
|
app.post("/api/translation/test", async () => {
|
|
15
16
|
return deps.translationService.testProvider();
|
|
16
17
|
});
|
|
18
|
+
app.post("/api/tasks/:taskSlug/sessions/:role/translation/start", async (request) => {
|
|
19
|
+
const project = await requireCurrentProject(deps.projectService);
|
|
20
|
+
const role = parseRole(request.params.role);
|
|
21
|
+
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
22
|
+
return deps.translationService.startSession({
|
|
23
|
+
repoRoot: project.repoRoot,
|
|
24
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(task),
|
|
25
|
+
taskSlug: request.params.taskSlug,
|
|
26
|
+
role
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
app.get("/api/translation/sessions/:sessionId/events", async (request) => {
|
|
30
|
+
await requireCurrentProject(deps.projectService);
|
|
31
|
+
return deps.translationService.pollSessionEvents(request.params.sessionId, Number(request.query.after ?? "1"), request.query.limit === undefined ? undefined : Number(request.query.limit));
|
|
32
|
+
});
|
|
17
33
|
app.post("/api/tasks/:taskSlug/sessions/:role/translation/input", async (request) => {
|
|
18
34
|
const project = await requireCurrentProject(deps.projectService);
|
|
19
35
|
const role = parseRole(request.params.role);
|
|
20
|
-
await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
36
|
+
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
21
37
|
return deps.translationService.translateUserInput({
|
|
22
38
|
repoRoot: project.repoRoot,
|
|
39
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(task),
|
|
23
40
|
taskSlug: request.params.taskSlug,
|
|
24
41
|
role,
|
|
25
42
|
...(request.body ?? { text: "" })
|
|
@@ -28,9 +45,10 @@ export function registerTranslationRoutes(app, deps) {
|
|
|
28
45
|
app.post("/api/tasks/:taskSlug/sessions/:role/translation/send", async (request) => {
|
|
29
46
|
const project = await requireCurrentProject(deps.projectService);
|
|
30
47
|
const role = parseRole(request.params.role);
|
|
31
|
-
await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
48
|
+
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
32
49
|
await deps.translationService.sendTranslatedInput({
|
|
33
50
|
repoRoot: project.repoRoot,
|
|
51
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(task),
|
|
34
52
|
taskSlug: request.params.taskSlug,
|
|
35
53
|
role,
|
|
36
54
|
englishText: request.body?.englishText ?? ""
|
|
@@ -39,7 +57,7 @@ export function registerTranslationRoutes(app, deps) {
|
|
|
39
57
|
});
|
|
40
58
|
app.post("/api/translation/sessions/:sessionId/clear", async (request) => {
|
|
41
59
|
await requireCurrentProject(deps.projectService);
|
|
42
|
-
deps.translationService.clearSession(request.params.sessionId);
|
|
60
|
+
await deps.translationService.clearSession(request.params.sessionId);
|
|
43
61
|
return { ok: true };
|
|
44
62
|
});
|
|
45
63
|
app.post("/api/translation/sessions/:sessionId/retry/:translationId", async (request) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
2
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
3
|
+
const DEFAULT_ENTER_DELAY_MS = 75;
|
|
4
|
+
export async function submitTerminalInput(runtime, sessionId, text, options = {}) {
|
|
5
|
+
runtime.write(sessionId, formatTerminalPaste(text));
|
|
6
|
+
await delay(options.enterDelayMs ?? DEFAULT_ENTER_DELAY_MS);
|
|
7
|
+
runtime.write(sessionId, "\r");
|
|
8
|
+
}
|
|
9
|
+
export function formatTerminalPaste(text) {
|
|
10
|
+
return `${BRACKETED_PASTE_START}${normalizeTerminalSubmitText(text)}${BRACKETED_PASTE_END}`;
|
|
11
|
+
}
|
|
12
|
+
export function normalizeTerminalSubmitText(text) {
|
|
13
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "");
|
|
14
|
+
}
|
|
15
|
+
function delay(ms) {
|
|
16
|
+
if (ms <= 0) {
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
}
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
package/dist/backend/server.js
CHANGED
|
@@ -21,6 +21,7 @@ import { createMessageService } from "./services/message-service.js";
|
|
|
21
21
|
import { createStatusService } from "./services/status-service.js";
|
|
22
22
|
import { createTaskService } from "./services/task-service.js";
|
|
23
23
|
import { createTranslationService } from "./services/translation-service.js";
|
|
24
|
+
import { registerAppSettingsRoutes } from "./api/app-settings-routes.js";
|
|
24
25
|
import { registerArtifactRoutes } from "./api/artifact-routes.js";
|
|
25
26
|
import { registerHarnessRoutes } from "./api/harness-routes.js";
|
|
26
27
|
import { registerMessageRoutes } from "./api/message-routes.js";
|
|
@@ -29,7 +30,6 @@ import { registerSessionRoutes } from "./api/session-routes.js";
|
|
|
29
30
|
import { registerTaskRoutes } from "./api/task-routes.js";
|
|
30
31
|
import { registerTranslationRoutes } from "./api/translation-routes.js";
|
|
31
32
|
import { registerTerminalWs } from "./ws/terminal-ws.js";
|
|
32
|
-
import { registerTranslationWs } from "./ws/translation-ws.js";
|
|
33
33
|
import { toVcmError } from "./errors.js";
|
|
34
34
|
export async function createServer(deps, options = {}) {
|
|
35
35
|
const app = Fastify({
|
|
@@ -45,6 +45,7 @@ export async function createServer(deps, options = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
registerAppSettingsRoutes(app, { appSettings: deps.appSettings });
|
|
48
49
|
registerProjectRoutes(app, { projectService: deps.projectService });
|
|
49
50
|
registerHarnessRoutes(app, {
|
|
50
51
|
projectService: deps.projectService,
|
|
@@ -53,13 +54,15 @@ export async function createServer(deps, options = {}) {
|
|
|
53
54
|
registerTaskRoutes(app, {
|
|
54
55
|
projectService: deps.projectService,
|
|
55
56
|
taskService: deps.taskService,
|
|
57
|
+
sessionService: deps.sessionService,
|
|
56
58
|
statusService: deps.statusService,
|
|
57
|
-
|
|
59
|
+
translationService: deps.translationService
|
|
58
60
|
});
|
|
59
61
|
registerSessionRoutes(app, {
|
|
60
62
|
projectService: deps.projectService,
|
|
61
63
|
sessionService: deps.sessionService,
|
|
62
|
-
commandDispatcher: deps.commandDispatcher
|
|
64
|
+
commandDispatcher: deps.commandDispatcher,
|
|
65
|
+
translationService: deps.translationService
|
|
63
66
|
});
|
|
64
67
|
registerArtifactRoutes(app, {
|
|
65
68
|
projectService: deps.projectService,
|
|
@@ -77,7 +80,6 @@ export async function createServer(deps, options = {}) {
|
|
|
77
80
|
translationService: deps.translationService
|
|
78
81
|
});
|
|
79
82
|
registerTerminalWs(app, { runtime: deps.runtime });
|
|
80
|
-
registerTranslationWs(app, { translationService: deps.translationService });
|
|
81
83
|
if (options.staticDir) {
|
|
82
84
|
await app.register(fastifyStatic, {
|
|
83
85
|
root: options.staticDir,
|
|
@@ -149,10 +151,13 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
149
151
|
sessionRegistry: registry,
|
|
150
152
|
transcripts: createClaudeTranscriptService(),
|
|
151
153
|
sessionService,
|
|
154
|
+
fs,
|
|
155
|
+
projectService,
|
|
152
156
|
appSettings,
|
|
153
157
|
provider: createOpenAiCompatibleTranslationProvider()
|
|
154
158
|
});
|
|
155
159
|
return {
|
|
160
|
+
appSettings,
|
|
156
161
|
projectService,
|
|
157
162
|
taskService,
|
|
158
163
|
sessionService,
|
|
@@ -52,6 +52,21 @@ export function createAppSettingsService(deps) {
|
|
|
52
52
|
}
|
|
53
53
|
return {
|
|
54
54
|
loadSettings,
|
|
55
|
+
async getPreferences() {
|
|
56
|
+
return (await loadSettings()).preferences;
|
|
57
|
+
},
|
|
58
|
+
async updatePreferences(input) {
|
|
59
|
+
const current = await loadSettings();
|
|
60
|
+
const preferences = normalizePreferences({
|
|
61
|
+
...current.preferences,
|
|
62
|
+
...input
|
|
63
|
+
});
|
|
64
|
+
await saveSettings({
|
|
65
|
+
...current,
|
|
66
|
+
preferences
|
|
67
|
+
});
|
|
68
|
+
return preferences;
|
|
69
|
+
},
|
|
55
70
|
async updateTranslationConfig(config) {
|
|
56
71
|
const current = await loadSettings();
|
|
57
72
|
const translation = normalizeTranslationConfig(config) ?? { settings: {}, secrets: {} };
|
|
@@ -157,10 +172,23 @@ function normalizeProjectIndexFile(input) {
|
|
|
157
172
|
function normalizeSettingsFile(input) {
|
|
158
173
|
return {
|
|
159
174
|
version: 1,
|
|
175
|
+
preferences: normalizePreferences(input.preferences),
|
|
160
176
|
translation: normalizeTranslationConfig(input.translation),
|
|
161
177
|
recentRepositoryPaths: normalizeRecentRepositoryPaths(input.recentRepositoryPaths)
|
|
162
178
|
};
|
|
163
179
|
}
|
|
180
|
+
function normalizePreferences(input) {
|
|
181
|
+
const candidate = isObject(input) ? input : {};
|
|
182
|
+
return {
|
|
183
|
+
themeMode: normalizeThemeMode(candidate.themeMode)
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function normalizeThemeMode(input) {
|
|
187
|
+
if (input === "light" || input === "dark" || input === "system") {
|
|
188
|
+
return input;
|
|
189
|
+
}
|
|
190
|
+
return "system";
|
|
191
|
+
}
|
|
164
192
|
function normalizeTranslationConfig(input) {
|
|
165
193
|
if (!input || typeof input !== "object") {
|
|
166
194
|
return undefined;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, watch as fsWatch } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
const DEFAULT_TAIL_POLL_INTERVAL_MS =
|
|
4
|
+
const DEFAULT_TAIL_POLL_INTERVAL_MS = 200;
|
|
5
5
|
/**
|
|
6
6
|
* Adapted from CodingForMoney/cc-pm's transcript tailer.
|
|
7
7
|
*
|
|
@@ -37,16 +37,16 @@ export class TranscriptTail {
|
|
|
37
37
|
this.buffer = "";
|
|
38
38
|
try {
|
|
39
39
|
this.watcher = fsWatch(this.path, () => {
|
|
40
|
-
this.scheduleFlush();
|
|
40
|
+
this.scheduleFlush("watch");
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
43
|
catch {
|
|
44
44
|
this.watcher = null;
|
|
45
45
|
}
|
|
46
46
|
this.pollTimer = setInterval(() => {
|
|
47
|
-
this.scheduleFlush();
|
|
47
|
+
this.scheduleFlush("poll");
|
|
48
48
|
}, opts?.pollIntervalMs ?? DEFAULT_TAIL_POLL_INTERVAL_MS);
|
|
49
|
-
this.scheduleFlush();
|
|
49
|
+
this.scheduleFlush("initial");
|
|
50
50
|
}
|
|
51
51
|
stop() {
|
|
52
52
|
if (this.watcher) {
|
|
@@ -58,23 +58,26 @@ export class TranscriptTail {
|
|
|
58
58
|
this.pollTimer = null;
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
|
-
scheduleFlush() {
|
|
61
|
+
scheduleFlush(source) {
|
|
62
62
|
if (this.flushing || this.flushScheduled) {
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
this.flushScheduled = true;
|
|
66
66
|
setImmediate(() => {
|
|
67
67
|
this.flushScheduled = false;
|
|
68
|
-
this.flush();
|
|
68
|
+
this.flush(source);
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
|
-
flush() {
|
|
71
|
+
flush(source) {
|
|
72
72
|
if (this.flushing) {
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
75
|
this.flushing = true;
|
|
76
76
|
try {
|
|
77
77
|
const stat = statSync(this.path);
|
|
78
|
+
if (source === "poll") {
|
|
79
|
+
this.handlers.onPoll?.(new Date().toISOString());
|
|
80
|
+
}
|
|
78
81
|
if (stat.size < this.offset) {
|
|
79
82
|
this.offset = stat.size;
|
|
80
83
|
this.buffer = "";
|
|
@@ -173,7 +176,8 @@ export function createClaudeTranscriptService() {
|
|
|
173
176
|
try {
|
|
174
177
|
tail = new TranscriptTail(transcriptPath, {
|
|
175
178
|
onContent: listener,
|
|
176
|
-
onError: options.onError
|
|
179
|
+
onError: options.onError,
|
|
180
|
+
onPoll: options.onPoll
|
|
177
181
|
});
|
|
178
182
|
tail.start({
|
|
179
183
|
replayLastN: options.replayLastN,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VcmError } from "../errors.js";
|
|
2
|
+
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
2
3
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
3
4
|
export function createCommandDispatcher(deps) {
|
|
4
5
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
@@ -26,7 +27,7 @@ export function createCommandDispatcher(deps) {
|
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
const instruction = `Please read and execute the role command at: ${commandPath}`;
|
|
29
|
-
deps.runtime
|
|
30
|
+
await submitTerminalInput(deps.runtime, session.id, instruction);
|
|
30
31
|
return {
|
|
31
32
|
taskSlug: input.taskSlug,
|
|
32
33
|
role: input.role,
|
|
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { ROLE_NAMES } from "../../shared/constants.js";
|
|
4
4
|
import { VcmError } from "../errors.js";
|
|
5
5
|
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
6
|
+
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
6
7
|
import { renderManualStagePrompt, renderMessageEnvelope } from "../templates/message-envelope.js";
|
|
7
8
|
const PM_ROLE = "project-manager";
|
|
8
9
|
const PM_TO_ROLE_TYPES = new Set(["task", "question", "review-request", "revise", "cancel"]);
|
|
@@ -12,7 +13,7 @@ export function createMessageService(deps) {
|
|
|
12
13
|
const id = deps.id ?? (() => `msg_${randomUUID()}`);
|
|
13
14
|
return {
|
|
14
15
|
listMessages(input) {
|
|
15
|
-
return readLatestMessages(deps.fs, getMessagesPath(input
|
|
16
|
+
return readLatestMessages(deps.fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
16
17
|
},
|
|
17
18
|
async sendMessage(input) {
|
|
18
19
|
await deps.taskService.loadTask(input.repoRoot, input.taskSlug);
|
|
@@ -56,7 +57,7 @@ export function createMessageService(deps) {
|
|
|
56
57
|
deliveredAt: timestamp,
|
|
57
58
|
failureReason: undefined
|
|
58
59
|
};
|
|
59
|
-
deps.runtime
|
|
60
|
+
await submitTerminalInput(deps.runtime, session.id, renderMessageEnvelope(delivered));
|
|
60
61
|
await appendMessageSnapshot(deps.fs, input, delivered);
|
|
61
62
|
return { message: delivered, delivered: true, requiresUserApproval: false };
|
|
62
63
|
},
|
|
@@ -95,7 +96,7 @@ export function createMessageService(deps) {
|
|
|
95
96
|
return rejected;
|
|
96
97
|
},
|
|
97
98
|
async getOrchestrationState(input) {
|
|
98
|
-
const statePath = getOrchestrationStatePath(input
|
|
99
|
+
const statePath = getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug);
|
|
99
100
|
if (!(await deps.fs.pathExists(statePath))) {
|
|
100
101
|
return {
|
|
101
102
|
taskSlug: input.taskSlug,
|
|
@@ -114,7 +115,7 @@ export function createMessageService(deps) {
|
|
|
114
115
|
paused: input.paused ?? current.paused,
|
|
115
116
|
updatedAt: now()
|
|
116
117
|
};
|
|
117
|
-
await deps.fs.writeJsonAtomic(getOrchestrationStatePath(input
|
|
118
|
+
await deps.fs.writeJsonAtomic(getOrchestrationStatePath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), next);
|
|
118
119
|
return next;
|
|
119
120
|
}
|
|
120
121
|
};
|
|
@@ -183,7 +184,7 @@ ${artifactRefs}
|
|
|
183
184
|
`;
|
|
184
185
|
}
|
|
185
186
|
async function getMessageOrThrow(fs, input) {
|
|
186
|
-
const messages = await readLatestMessages(fs, getMessagesPath(input
|
|
187
|
+
const messages = await readLatestMessages(fs, getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug));
|
|
187
188
|
const message = messages.find((candidate) => candidate.id === input.messageId);
|
|
188
189
|
if (!message) {
|
|
189
190
|
throw new VcmError({
|
|
@@ -207,7 +208,7 @@ async function readLatestMessages(fs, messagesPath) {
|
|
|
207
208
|
return [...latest.values()].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
|
208
209
|
}
|
|
209
210
|
async function appendMessageSnapshot(fs, input, message) {
|
|
210
|
-
await fs.appendText(getMessagesPath(input
|
|
211
|
+
await fs.appendText(getMessagesPath(getStateRepoRoot(input), input.stateRoot, input.taskSlug), `${JSON.stringify(message)}\n`);
|
|
211
212
|
}
|
|
212
213
|
function getMessagesPath(repoRoot, stateRoot, taskSlug) {
|
|
213
214
|
return path.join(repoRoot, stateRoot, "messages", `${taskSlug}.jsonl`);
|
|
@@ -215,3 +216,6 @@ function getMessagesPath(repoRoot, stateRoot, taskSlug) {
|
|
|
215
216
|
function getOrchestrationStatePath(repoRoot, stateRoot, taskSlug) {
|
|
216
217
|
return path.join(repoRoot, stateRoot, "orchestration", `${taskSlug}.json`);
|
|
217
218
|
}
|
|
219
|
+
function getStateRepoRoot(input) {
|
|
220
|
+
return input.stateRepoRoot ?? input.repoRoot;
|
|
221
|
+
}
|
|
@@ -31,9 +31,6 @@ export function createProjectService(deps) {
|
|
|
31
31
|
const config = await this.loadConfig(repoRoot);
|
|
32
32
|
await deps.fs.ensureDir(path.join(repoRoot, config.handoffRoot));
|
|
33
33
|
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "tasks"));
|
|
34
|
-
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "sessions"));
|
|
35
|
-
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "messages"));
|
|
36
|
-
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "orchestration"));
|
|
37
34
|
await deps.fs.ensureDir(path.join(repoRoot, config.stateRoot, "worktrees"));
|
|
38
35
|
await this.saveConfig(config, true);
|
|
39
36
|
const warnings = [];
|
|
@@ -16,7 +16,7 @@ export function createSessionService(deps) {
|
|
|
16
16
|
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
17
17
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
18
18
|
const paths = deps.artifactService.getHandoffPaths(taskRepoRoot, task.handoffDir);
|
|
19
|
-
const persisted = await loadPersistedRoleRecord(deps.fs,
|
|
19
|
+
const persisted = await loadPersistedRoleRecord(deps.fs, taskRepoRoot, config.stateRoot, taskSlug, role);
|
|
20
20
|
const permissionMode = input.permissionMode ?? persisted?.permissionMode ?? "default";
|
|
21
21
|
const claudeSessionId = launchMode === "resume"
|
|
22
22
|
? persisted?.claudeSessionId
|
|
@@ -73,7 +73,7 @@ export function createSessionService(deps) {
|
|
|
73
73
|
exitCode: runtimeSession.exitCode
|
|
74
74
|
};
|
|
75
75
|
deps.registry.upsert(record);
|
|
76
|
-
await persistTaskSession(deps.fs,
|
|
76
|
+
await persistTaskSession(deps.fs, taskRepoRoot, config.stateRoot, record);
|
|
77
77
|
await deps.taskService.updateTaskStatus(repoRoot, taskSlug, "running");
|
|
78
78
|
return record;
|
|
79
79
|
}
|
|
@@ -103,7 +103,8 @@ export function createSessionService(deps) {
|
|
|
103
103
|
};
|
|
104
104
|
deps.registry.upsert(updated);
|
|
105
105
|
const config = await deps.projectService.loadConfig(repoRoot);
|
|
106
|
-
await
|
|
106
|
+
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
107
|
+
await persistTaskSession(deps.fs, getTaskRuntimeRepoRoot(task), config.stateRoot, updated);
|
|
107
108
|
return updated;
|
|
108
109
|
},
|
|
109
110
|
async restartRoleSession(repoRoot, taskSlug, role, input = {}) {
|
|
@@ -119,8 +120,10 @@ export function createSessionService(deps) {
|
|
|
119
120
|
},
|
|
120
121
|
async getRoleSession(repoRoot, taskSlug, role) {
|
|
121
122
|
const config = await deps.projectService.loadConfig(repoRoot);
|
|
123
|
+
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
124
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
122
125
|
const record = deps.registry.getByRole(taskSlug, role)
|
|
123
|
-
?? await loadPersistedRoleRecord(deps.fs,
|
|
126
|
+
?? await loadPersistedRoleRecord(deps.fs, taskRepoRoot, config.stateRoot, taskSlug, role);
|
|
124
127
|
if (!record) {
|
|
125
128
|
return undefined;
|
|
126
129
|
}
|