vibe-coding-master 0.0.8 → 0.0.10

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.
Files changed (30) hide show
  1. package/README.md +35 -24
  2. package/dist/backend/adapters/filesystem.js +0 -7
  3. package/dist/backend/api/app-settings-routes.js +8 -0
  4. package/dist/backend/api/message-routes.js +3 -1
  5. package/dist/backend/api/session-routes.js +7 -1
  6. package/dist/backend/api/task-routes.js +3 -10
  7. package/dist/backend/api/translation-routes.js +21 -3
  8. package/dist/backend/runtime/terminal-submit.js +20 -0
  9. package/dist/backend/server.js +8 -4
  10. package/dist/backend/services/app-settings-service.js +118 -15
  11. package/dist/backend/services/claude-transcript-service.js +12 -8
  12. package/dist/backend/services/command-dispatcher.js +2 -1
  13. package/dist/backend/services/message-service.js +10 -6
  14. package/dist/backend/services/project-service.js +5 -27
  15. package/dist/backend/services/session-service.js +7 -4
  16. package/dist/backend/services/task-service.js +66 -57
  17. package/dist/backend/services/translation-service.js +264 -77
  18. package/dist/backend/templates/harness/gitignore.js +1 -4
  19. package/dist/shared/types/app-settings.js +1 -0
  20. package/dist-frontend/assets/index-B1vIIwLq.js +88 -0
  21. package/dist-frontend/assets/index-DPyKuEOz.css +32 -0
  22. package/dist-frontend/index.html +2 -2
  23. package/docs/cc-best-practices.md +4 -4
  24. package/docs/product-design.md +71 -31
  25. package/docs/v1-architecture-design.md +90 -56
  26. package/docs/v1-implementation-plan.md +76 -62
  27. package/package.json +3 -1
  28. package/dist/backend/ws/translation-ws.js +0 -35
  29. package/dist-frontend/assets/index-CuiNNOzj.css +0 -32
  30. 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
- 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 worktree-backed task shows a destructive confirmation, 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 check 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
@@ -246,11 +255,10 @@ For `.gitignore`, VCM uses a gitignore-native managed block:
246
255
  ```gitignore
247
256
  # VCM:BEGIN version=1
248
257
  .ai/vcm/
249
- .vcm/
250
258
  # VCM:END
251
259
  ```
252
260
 
253
- `.ai/vcm/` is the active VCM local control area. `.vcm/` is ignored only as a legacy safety rule so older local state cannot be accidentally committed during migration.
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.
254
262
 
255
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.
256
264
 
@@ -282,8 +290,8 @@ vcmctl inbox
282
290
  Durable message and handoff files:
283
291
 
284
292
  ```text
285
- .ai/vcm/messages/<task>.jsonl
286
- .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
287
295
  .ai/handoffs/<task>/messages/<message-id>.md
288
296
  .ai/handoffs/<task>/role-commands/
289
297
  .ai/handoffs/<task>/logs/
@@ -317,7 +325,7 @@ The backend state model still contains a `paused` field for compatibility with e
317
325
  Each role session stores its Claude session id and transcript path under:
318
326
 
319
327
  ```text
320
- .ai/vcm/sessions/<task>.json
328
+ .ai/vcm/sessions/<task>.json # under the task runtime repo
321
329
  ```
322
330
 
323
331
  Session buttons behave as follows:
@@ -332,21 +340,24 @@ Session buttons behave as follows:
332
340
  For a connected repository, VCM uses:
333
341
 
334
342
  ```text
335
- .ai/vcm/config.json
336
- .ai/vcm/tasks/<task>.json
337
- .ai/vcm/sessions/<task>.json
338
- .ai/vcm/messages/<task>.jsonl
339
- .ai/vcm/orchestration/<task>.json
340
- .ai/vcm/worktrees/<task>/
341
- .ai/handoffs/<task>/architecture-plan.md
342
- .ai/handoffs/<task>/implementation-log.md
343
- .ai/handoffs/<task>/validation-log.md
344
- .ai/handoffs/<task>/review-report.md
345
- .ai/handoffs/<task>/docs-sync-report.md
346
- .ai/handoffs/<task>/role-commands/{architect,coder,reviewer}.md
347
- .ai/handoffs/<task>/logs/{project-manager,architect,coder,reviewer}.log
343
+ ~/.vcm/projects/<project-id>/config.json
344
+ <baseRepoRoot>/.ai/vcm/tasks/<task>.json
345
+ <baseRepoRoot>/.ai/vcm/worktrees/<task>/
346
+ <taskRepoRoot>/.ai/vcm/sessions/<task>.json
347
+ <taskRepoRoot>/.ai/vcm/messages/<task>.jsonl
348
+ <taskRepoRoot>/.ai/vcm/orchestration/<task>.json
349
+ <taskRepoRoot>/.ai/vcm/translation/<task>/
350
+ <taskRepoRoot>/.ai/handoffs/<task>/architecture-plan.md
351
+ <taskRepoRoot>/.ai/handoffs/<task>/implementation-log.md
352
+ <taskRepoRoot>/.ai/handoffs/<task>/validation-log.md
353
+ <taskRepoRoot>/.ai/handoffs/<task>/review-report.md
354
+ <taskRepoRoot>/.ai/handoffs/<task>/docs-sync-report.md
355
+ <taskRepoRoot>/.ai/handoffs/<task>/role-commands/{architect,coder,reviewer}.md
356
+ <taskRepoRoot>/.ai/handoffs/<task>/logs/{project-manager,architect,coder,reviewer}.log
348
357
  ```
349
358
 
359
+ 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.
360
+
350
361
  ## Packaging
351
362
 
352
363
  The npm package publishes built output, not raw TypeScript entry files. `package.json` includes:
@@ -47,13 +47,6 @@ export function createNodeFileSystemAdapter() {
47
47
  await this.writeText(targetPath, content);
48
48
  return true;
49
49
  },
50
- async copyDir(sourcePath, targetPath) {
51
- await fs.cp(sourcePath, targetPath, {
52
- recursive: true,
53
- force: false,
54
- errorOnExist: false
55
- });
56
- },
57
50
  async removePath(targetPath, options = {}) {
58
51
  await fs.rm(targetPath, {
59
52
  recursive: options.recursive ?? false,
@@ -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,16 +19,8 @@ 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 deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), request.params.taskSlug, { clearCache: true });
31
24
  return deps.taskService.cleanupTask(project.repoRoot, request.params.taskSlug, request.body ?? {});
32
25
  });
33
26
  }
@@ -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,
@@ -54,12 +55,13 @@ export async function createServer(deps, options = {}) {
54
55
  projectService: deps.projectService,
55
56
  taskService: deps.taskService,
56
57
  statusService: deps.statusService,
57
- sessionService: deps.sessionService
58
+ translationService: deps.translationService
58
59
  });
59
60
  registerSessionRoutes(app, {
60
61
  projectService: deps.projectService,
61
62
  sessionService: deps.sessionService,
62
- commandDispatcher: deps.commandDispatcher
63
+ commandDispatcher: deps.commandDispatcher,
64
+ translationService: deps.translationService
63
65
  });
64
66
  registerArtifactRoutes(app, {
65
67
  projectService: deps.projectService,
@@ -77,7 +79,6 @@ export async function createServer(deps, options = {}) {
77
79
  translationService: deps.translationService
78
80
  });
79
81
  registerTerminalWs(app, { runtime: deps.runtime });
80
- registerTranslationWs(app, { translationService: deps.translationService });
81
82
  if (options.staticDir) {
82
83
  await app.register(fastifyStatic, {
83
84
  root: options.staticDir,
@@ -149,10 +150,13 @@ export function createDefaultServerDeps(options = {}) {
149
150
  sessionRegistry: registry,
150
151
  transcripts: createClaudeTranscriptService(),
151
152
  sessionService,
153
+ fs,
154
+ projectService,
152
155
  appSettings,
153
156
  provider: createOpenAiCompatibleTranslationProvider()
154
157
  });
155
158
  return {
159
+ appSettings,
156
160
  projectService,
157
161
  taskService,
158
162
  sessionService,
@@ -1,13 +1,13 @@
1
1
  import path from "node:path";
2
2
  import { homedir } from "node:os";
3
+ import { createHash } from "node:crypto";
3
4
  const MAX_RECENT_REPOSITORIES = 5;
4
5
  export function createAppSettingsService(deps) {
5
6
  const settingsPath = deps.settingsPath ?? path.join(homedir(), ".vcm", "settings.json");
6
- const legacySettingsPath = deps.legacySettingsPath
7
- ?? path.join(homedir(), ".vibe-coding-master", "settings.json");
8
- const legacyTranslationPath = deps.legacyTranslationPath
9
- ?? path.join(homedir(), ".vibe-coding-master", "translation.json");
7
+ const settingsRoot = path.dirname(settingsPath);
8
+ const projectIndexPath = path.join(settingsRoot, "projects", "index.json");
10
9
  let cachedSettings = null;
10
+ let cachedProjectIndex = null;
11
11
  async function loadSettings() {
12
12
  if (cachedSettings) {
13
13
  return cachedSettings;
@@ -17,20 +17,9 @@ export function createAppSettingsService(deps) {
17
17
  if (await deps.fs.pathExists(settingsPath)) {
18
18
  raw = await deps.fs.readJson(settingsPath);
19
19
  }
20
- else if (await deps.fs.pathExists(legacySettingsPath)) {
21
- raw = await deps.fs.readJson(legacySettingsPath);
22
- shouldSave = true;
23
- }
24
20
  else {
25
21
  shouldSave = true;
26
22
  }
27
- if (!raw.translation && await deps.fs.pathExists(legacyTranslationPath)) {
28
- raw = {
29
- ...raw,
30
- translation: normalizeTranslationConfig(await deps.fs.readJson(legacyTranslationPath))
31
- };
32
- shouldSave = true;
33
- }
34
23
  cachedSettings = normalizeSettingsFile(raw);
35
24
  if (shouldSave) {
36
25
  await saveSettings(cachedSettings);
@@ -41,8 +30,43 @@ export function createAppSettingsService(deps) {
41
30
  cachedSettings = settings;
42
31
  await deps.fs.writeJsonAtomic(settingsPath, settings);
43
32
  }
33
+ async function loadProjectIndex() {
34
+ if (cachedProjectIndex) {
35
+ return cachedProjectIndex;
36
+ }
37
+ if (await deps.fs.pathExists(projectIndexPath)) {
38
+ cachedProjectIndex = normalizeProjectIndexFile(await deps.fs.readJson(projectIndexPath));
39
+ }
40
+ else {
41
+ cachedProjectIndex = { version: 1, projects: [] };
42
+ await saveProjectIndex(cachedProjectIndex);
43
+ }
44
+ return cachedProjectIndex;
45
+ }
46
+ async function saveProjectIndex(index) {
47
+ cachedProjectIndex = normalizeProjectIndexFile(index);
48
+ await deps.fs.writeJsonAtomic(projectIndexPath, cachedProjectIndex);
49
+ }
50
+ function getProjectConfigPath(repoRoot) {
51
+ return path.join(settingsRoot, "projects", getProjectId(repoRoot), "config.json");
52
+ }
44
53
  return {
45
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
+ },
46
70
  async updateTranslationConfig(config) {
47
71
  const current = await loadSettings();
48
72
  const translation = normalizeTranslationConfig(config) ?? { settings: {}, secrets: {} };
@@ -74,18 +98,97 @@ export function createAppSettingsService(deps) {
74
98
  });
75
99
  return recentRepositoryPaths;
76
100
  },
101
+ loadProjectIndex,
102
+ async loadProjectConfig(repoRoot) {
103
+ const configPath = getProjectConfigPath(repoRoot);
104
+ if (!(await deps.fs.pathExists(configPath))) {
105
+ return undefined;
106
+ }
107
+ return deps.fs.readJson(configPath);
108
+ },
109
+ async saveProjectConfig(config) {
110
+ const configPath = getProjectConfigPath(config.repoRoot);
111
+ await deps.fs.writeJsonAtomic(configPath, config);
112
+ const projectId = getProjectId(config.repoRoot);
113
+ const current = await loadProjectIndex();
114
+ const projects = [
115
+ {
116
+ projectId,
117
+ repoRoot: config.repoRoot,
118
+ configPath,
119
+ lastOpenedAt: new Date().toISOString()
120
+ },
121
+ ...current.projects.filter((entry) => entry.projectId !== projectId)
122
+ ];
123
+ await saveProjectIndex({
124
+ version: 1,
125
+ projects
126
+ });
127
+ return config;
128
+ },
77
129
  getSettingsPath() {
78
130
  return settingsPath;
131
+ },
132
+ getProjectIndexPath() {
133
+ return projectIndexPath;
134
+ },
135
+ getProjectConfigPath
136
+ };
137
+ }
138
+ export function getProjectId(repoRoot) {
139
+ return createHash("sha256")
140
+ .update(path.resolve(repoRoot))
141
+ .digest("hex")
142
+ .slice(0, 16);
143
+ }
144
+ function normalizeProjectIndexFile(input) {
145
+ const rawProjects = Array.isArray(input.projects) ? input.projects : [];
146
+ const projects = [];
147
+ const seen = new Set();
148
+ for (const value of rawProjects) {
149
+ if (!isObject(value)) {
150
+ continue;
79
151
  }
152
+ const projectId = typeof value.projectId === "string" ? value.projectId.trim() : "";
153
+ const repoRoot = typeof value.repoRoot === "string" ? value.repoRoot.trim() : "";
154
+ const configPath = typeof value.configPath === "string" ? value.configPath.trim() : "";
155
+ const lastOpenedAt = typeof value.lastOpenedAt === "string" ? value.lastOpenedAt.trim() : "";
156
+ if (!projectId || !repoRoot || !configPath || seen.has(projectId)) {
157
+ continue;
158
+ }
159
+ seen.add(projectId);
160
+ projects.push({
161
+ projectId,
162
+ repoRoot,
163
+ configPath,
164
+ lastOpenedAt: lastOpenedAt || new Date(0).toISOString()
165
+ });
166
+ }
167
+ return {
168
+ version: 1,
169
+ projects
80
170
  };
81
171
  }
82
172
  function normalizeSettingsFile(input) {
83
173
  return {
84
174
  version: 1,
175
+ preferences: normalizePreferences(input.preferences),
85
176
  translation: normalizeTranslationConfig(input.translation),
86
177
  recentRepositoryPaths: normalizeRecentRepositoryPaths(input.recentRepositoryPaths)
87
178
  };
88
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
+ }
89
192
  function normalizeTranslationConfig(input) {
90
193
  if (!input || typeof input !== "object") {
91
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,