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 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
- 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 after creation.
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 guarded cleanup action that removes the task worktree and VCM task metadata. Cleanup refuses to remove a dirty task worktree unless a force cleanup is requested through the API. Branch deletion stays separate and requires explicit confirmation.
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 default prompts and allows per-slot overrides.
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 for task state, session state, orchestration state, and task worktrees.
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
- - Clicking `Stage` writes a prompt into the target embedded terminal input line.
303
- - VCM does not press Enter for the user.
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
- .ai/vcm/tasks/<task>.json
336
- .ai/vcm/sessions/<task>.json
337
- .ai/vcm/messages/<task>.jsonl
338
- .ai/vcm/orchestration/<task>.json
339
- .ai/vcm/worktrees/<task>/
340
- .ai/handoffs/<task>/architecture-plan.md
341
- .ai/handoffs/<task>/implementation-log.md
342
- .ai/handoffs/<task>/validation-log.md
343
- .ai/handoffs/<task>/review-report.md
344
- .ai/handoffs/<task>/docs-sync-report.md
345
- .ai/handoffs/<task>/role-commands/{architect,coder,reviewer}.md
346
- .ai/handoffs/<task>/logs/{project-manager,architect,coder,reviewer}.log
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. `.ai/vcm/` stays repository-local runtime state for tasks, sessions, messages, orchestration snapshots, and nested task worktrees.
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: getTaskRuntimeRepoRoot(task),
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
- return deps.sessionService.stopRoleSession(project.repoRoot, request.params.taskSlug, role);
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 sessions = await deps.sessionService.listRoleSessions(project.repoRoot, request.params.taskSlug);
22
- const running = sessions.filter((session) => session.status === "running");
23
- if (running.length > 0) {
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
+ }
@@ -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
- sessionService: deps.sessionService
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 = 500;
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.write(session.id, `${instruction}\r`);
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.repoRoot, input.stateRoot, input.taskSlug));
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.write(session.id, `${renderMessageEnvelope(delivered)}\r`);
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.repoRoot, input.stateRoot, input.taskSlug);
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.repoRoot, input.stateRoot, input.taskSlug), next);
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.repoRoot, input.stateRoot, input.taskSlug));
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.repoRoot, input.stateRoot, input.taskSlug), `${JSON.stringify(message)}\n`);
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, repoRoot, config.stateRoot, taskSlug, role);
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, repoRoot, config.stateRoot, record);
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 persistTaskSession(deps.fs, repoRoot, config.stateRoot, updated);
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, repoRoot, config.stateRoot, taskSlug, role);
126
+ ?? await loadPersistedRoleRecord(deps.fs, taskRepoRoot, config.stateRoot, taskSlug, role);
124
127
  if (!record) {
125
128
  return undefined;
126
129
  }