@aiworker/web 1.23.0 → 1.23.2

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 (152) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-COGJG0Iv.js} +1 -1
  2. package/dist/assets/DiffView-BviYY3zL.js +4 -0
  3. package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-C26DvfVJ.js} +1 -1
  4. package/dist/assets/FilesView-D0ZU_nCt.js +13 -0
  5. package/dist/assets/{GitView-BXsS3jG6.js → GitView-CYgiDRH-.js} +4 -4
  6. package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-GsV9UScz.js} +1 -1
  7. package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-shqUS8nl.js} +1 -1
  8. package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-CQSTFp6m.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DJWP3A8X.js} +2 -2
  10. package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-CTuu4lgo.js} +1 -1
  11. package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-Dp53v2pl.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-B5l0djPS.js} +1 -1
  13. package/dist/assets/PlanView-CLUWkGiG.js +5 -0
  14. package/dist/assets/SettingsView-CojPQVHx.js +28 -0
  15. package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-DbgEnZ6T.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-Du-RZj-a.js} +6 -6
  17. package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-HBLSokoe.js} +2 -2
  18. package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-Bzp3hClU.js} +4 -4
  19. package/dist/assets/WalkthroughView-CpgA4s9w.js +4 -0
  20. package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-CI1PC_z2.js} +2 -2
  21. package/dist/assets/de-BX8b4hDD.js +10 -0
  22. package/dist/assets/es-e98cS-yM.js +16 -0
  23. package/dist/assets/fr-BOiqxJAs.js +14 -0
  24. package/dist/assets/index-BD8ILbxF.js +40 -0
  25. package/dist/assets/{index-jXSK5RrB.js → index-Cmn74sw-.js} +1 -1
  26. package/dist/assets/index-zhgg-SDz.css +1 -0
  27. package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-DVralkU-.js} +1 -1
  28. package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-CgTREsRX.js} +1 -1
  29. package/dist/assets/ja-aoO55hGu.js +16 -0
  30. package/dist/assets/{ko-idhXGF8j.js → ko-BGzFLogk.js} +3 -3
  31. package/dist/assets/{main-CzdPuL0s.js → main-BgfXQ-4g.js} +2 -2
  32. package/dist/assets/main-DGYUWmIt.js +930 -0
  33. package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-BesaNs_G.js} +3 -3
  34. package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-DHKXp-_I.js} +2 -2
  35. package/dist/assets/{mobile-mvxTo055.js → mobile-lXQ08VnX.js} +2 -2
  36. package/dist/assets/patchFileDiff-i1lTtNbn.js +13 -0
  37. package/dist/assets/{pl-CGZWUqZJ.js → pl-B5x19laq.js} +3 -3
  38. package/dist/assets/pt-BR-C3oU51Il.js +16 -0
  39. package/dist/assets/renderElectronMiniChatApp-CuO4ue6B.js +2 -0
  40. package/dist/assets/renderMobileApp-B3zFlHy4.js +25 -0
  41. package/dist/assets/runtimeEndpointReset-BLzrtrmj.js +41 -0
  42. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  43. package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-ctlcPvvx.js} +1 -1
  44. package/dist/assets/surface-COKb77Ua.js +1 -0
  45. package/dist/assets/tr-C0QQa7et.js +7 -0
  46. package/dist/assets/uk-CqLSe9wT.js +16 -0
  47. package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-B6OfEypo.js} +153 -155
  48. package/dist/assets/useGitComparison-BXZXEAW6.js +1 -0
  49. package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-CgbrpEWu.js} +2 -2
  50. package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-Me41OjBT.js} +1 -1
  51. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
  52. package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-SJuFp0a0.js} +2 -2
  53. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  54. package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
  55. package/dist/assets/{web-components-z7hfnS8n.js → web-components-D8GS8Ork.js} +1 -1
  56. package/dist/assets/zh-CN-iJEie4d6.js +16 -0
  57. package/dist/assets/zh-TW-CyslEwlK.js +16 -0
  58. package/dist/index.html +4 -4
  59. package/dist/mini-chat.html +4 -4
  60. package/dist/mobile.html +4 -4
  61. package/package.json +1 -1
  62. package/server/index.js +3 -13
  63. package/server/lib/agent-memory/runtime.js +7 -2
  64. package/server/lib/agent-memory/runtime.test.js +22 -0
  65. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  66. package/server/lib/event-stream/global-hub.js +29 -4
  67. package/server/lib/event-stream/global-hub.test.js +22 -0
  68. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  69. package/server/lib/event-stream/protocol.js +21 -7
  70. package/server/lib/event-stream/runtime.js +9 -6
  71. package/server/lib/event-stream/runtime.test.js +60 -0
  72. package/server/lib/git/DOCUMENTATION.md +10 -1
  73. package/server/lib/git/routes.js +21 -8
  74. package/server/lib/git/routes.test.js +78 -0
  75. package/server/lib/git/service.js +159 -4
  76. package/server/lib/git/service.test.js +74 -0
  77. package/server/lib/github/DOCUMENTATION.md +5 -0
  78. package/server/lib/github/routes.js +2 -2
  79. package/server/lib/message-queue/DOCUMENTATION.md +7 -0
  80. package/server/lib/message-queue/runtime.js +13 -0
  81. package/server/lib/message-queue/runtime.test.js +31 -1
  82. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  83. package/server/lib/notifications/emitter-runtime.js +4 -3
  84. package/server/lib/opencode/DOCUMENTATION.md +18 -4
  85. package/server/lib/opencode/env-runtime.js +5 -1
  86. package/server/lib/opencode/env-runtime.test.js +72 -0
  87. package/server/lib/opencode/feature-routes-runtime.js +15 -1
  88. package/server/lib/opencode/lifecycle.js +4 -3
  89. package/server/lib/opencode/lifecycle.test.js +29 -0
  90. package/server/lib/opencode/managed-plugin-config.js +1 -1
  91. package/server/lib/opencode/proxy.js +33 -0
  92. package/server/lib/opencode/settings-helpers.js +3 -0
  93. package/server/lib/opencode/settings-helpers.test.js +11 -1
  94. package/server/lib/opencode/settings-registry.json +3 -0
  95. package/server/lib/opencode/settings-runtime.js +36 -9
  96. package/server/lib/opencode/settings-runtime.test.js +158 -2
  97. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  98. package/server/lib/project-context/runtime.js +8 -2
  99. package/server/lib/project-context/runtime.test.js +54 -0
  100. package/server/lib/projects/DOCUMENTATION.md +2 -1
  101. package/server/lib/projects/project-config.js +38 -9
  102. package/server/lib/projects/project-config.test.js +97 -0
  103. package/server/lib/projects/project-id.js +26 -0
  104. package/server/lib/relay/DOCUMENTATION.md +20 -0
  105. package/server/lib/relay/host-client.js +7 -0
  106. package/server/lib/relay/host-client.test.js +14 -0
  107. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  108. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  109. package/server/lib/scheduled-tasks/runtime.js +12 -2
  110. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  111. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  112. package/server/lib/session-assist/context.js +127 -0
  113. package/server/lib/session-assist/context.test.js +132 -0
  114. package/server/lib/session-assist/prompt.js +54 -0
  115. package/server/lib/session-assist/runtime.js +111 -257
  116. package/server/lib/session-assist/runtime.test.js +203 -0
  117. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  118. package/server/lib/walkthrough/pr-source.test.js +21 -0
  119. package/server/lib/walkthrough/pull-request.js +7 -4
  120. package/server/lib/walkthrough/pull-request.test.js +16 -0
  121. package/server/lib/walkthrough/routes.js +19 -0
  122. package/server/lib/walkthrough/routes.test.js +26 -0
  123. package/server/lib/walkthrough/sources.js +13 -2
  124. package/server/opencode-worktree-readiness.test.js +130 -0
  125. package/dist/assets/DiffView-DXjVsEOw.js +0 -4
  126. package/dist/assets/FilesView-CJdG0-wk.js +0 -17
  127. package/dist/assets/PlanView-CJTmOF6I.js +0 -5
  128. package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
  129. package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
  130. package/dist/assets/de-DCrsQ5zS.js +0 -10
  131. package/dist/assets/es-Dwv4YfH6.js +0 -16
  132. package/dist/assets/fr-BCWg4Cnx.js +0 -14
  133. package/dist/assets/index-B68h5S5B.css +0 -1
  134. package/dist/assets/index-DVwMarHI.js +0 -39
  135. package/dist/assets/ja-Bruw3RYC.js +0 -16
  136. package/dist/assets/main-BkyaQhJX.js +0 -930
  137. package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
  138. package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
  139. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
  140. package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
  141. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
  142. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
  143. package/dist/assets/surface-oMvhsog3.js +0 -1
  144. package/dist/assets/tr-BK_KiO2V.js +0 -7
  145. package/dist/assets/uk-CG74xV8N.js +0 -16
  146. package/dist/assets/useGitComparison-DH220WmO.js +0 -1
  147. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  148. package/dist/assets/zh-CN-T4AXofZI.js +0 -16
  149. package/dist/assets/zh-TW-Dte40sxp.js +0 -16
  150. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  151. package/server/lib/mcp-reconnect/runtime.js +0 -122
  152. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -3,6 +3,7 @@ import fsPromises from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
+ import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
6
7
  import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
7
8
 
8
9
  const PROJECT_ID = 'path_dGVzdA';
@@ -200,6 +201,59 @@ describe('legacy migration', () => {
200
201
  });
201
202
  });
202
203
 
204
+ describe('long project ids', () => {
205
+ // A checkout nested deep enough that its `path_<base64url>` id is longer
206
+ // than a file or folder name may be; the storage lands under the bounded
207
+ // stem instead of failing with ENAMETOOLONG.
208
+ const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
209
+ const stem = projectConfigFileStemOf(longProjectId);
210
+
211
+ test('the id maps to a bounded folder name', () => {
212
+ expect(longProjectId.length).toBeGreaterThan(240);
213
+ expect(stem.startsWith('path_sha256_')).toBe(true);
214
+ expect(stem.length).toBeLessThan(100);
215
+ expect(runtime.contextPathFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'context.json'));
216
+ expect(runtime.plansDirFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'plans'));
217
+ // A short id keeps naming its folder itself.
218
+ expect(runtime.contextPathFor(PROJECT_ID)).toBe(contextPath());
219
+ });
220
+
221
+ test('a missing context reads as empty instead of failing on the legacy config lookup', async () => {
222
+ expect(await runtime.readContext(longProjectId)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
223
+ });
224
+
225
+ test('context and plans round-trip through the bounded folder', async () => {
226
+ await runtime.saveTodos(longProjectId, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
227
+ const { note } = await runtime.createNote(longProjectId, { body: 'kept' });
228
+ const { plan } = await runtime.createPlan(longProjectId, { title: 'Deep plan', body: 'step one' });
229
+
230
+ const context = await runtime.readContext(longProjectId);
231
+ expect(context.todos).toEqual([{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
232
+ expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
233
+ expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
234
+ expect((await runtime.readPlan(longProjectId, plan.id)).body).toBe('step one');
235
+
236
+ expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).todos).toHaveLength(1);
237
+ await fsPromises.access(path.join(projectsDirPath, stem, 'plans', plan.file));
238
+ expect(await fsPromises.readdir(projectsDirPath)).toEqual([stem]);
239
+ });
240
+
241
+ test('legacy keys are migrated out of the bounded config file', async () => {
242
+ const boundedConfigPath = path.join(projectsDirPath, `${stem}.json`);
243
+ await writeJson(boundedConfigPath, {
244
+ 'setup-worktree': ['bun install'],
245
+ projectNotes: 'legacy notes',
246
+ projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
247
+ });
248
+
249
+ const context = await runtime.readContext(longProjectId);
250
+ expect(context.notes.map((entry) => entry.body)).toEqual(['legacy notes']);
251
+ expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
252
+ expect(await readJson(boundedConfigPath)).toEqual({ 'setup-worktree': ['bun install'] });
253
+ expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).notes[0].body).toBe('legacy notes');
254
+ });
255
+ });
256
+
203
257
  describe('todos', () => {
204
258
  test('round-trips through disk', async () => {
205
259
  await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
@@ -63,7 +63,7 @@ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
63
63
 
64
64
  ## Modules
65
65
 
66
- - `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree.
66
+ - `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree. `projectConfigFileStemOf`: the stem that names the file and the sibling folder for an id, see the file name invariant below.
67
67
  - `project-config.js` — `createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
68
68
  - `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no AiWorker server; keep the two in sync.
69
69
  - `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
@@ -72,5 +72,6 @@ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
72
72
 
73
73
  - **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
74
74
  - **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
75
+ - **The file name is bounded, and so is the folder beside it.** The file is `<projectId>.json` and the per-project folder is `<projectId>/` while the id is at most 200 characters. A `path_<base64url>` id grows with the checkout path, so a deeply nested project (a path of roughly 150 characters or more) would otherwise get a name beyond the 255-byte limit and every write, lock, and temp file would fail with ENAMETOOLONG. Such an id is stored as `path_sha256_<hex digest of the id>.json` instead, with the folder `path_sha256_<digest>/` beside it (`projectConfigFileStemOf`; every composer of either path goes through it: `project-config.js`, `project-context/runtime.js`, `agent-memory/runtime.js`, and the id migration and orphan recovery in `opencode/settings-runtime.js`). The digest keeps the `path_` prefix so orphan recovery skips it. A file an older build managed to write under the long name is still read when the bounded file is missing and is moved to the bounded name by the next write; a malformed one is a read failure, not an empty project. A folder an older build created under the raw id (possible only for ids of 201 to 255 characters; longer names never got one) is moved into the bounded folder once at startup, by the project id migration in `opencode/settings-runtime.js`, with `context.json` merged by entry identity when both exist. The VS Code extension host mirrors both the naming rule and the legacy read-then-move for the file (`bridge-project-setup-runtime.ts`), because a VS Code-only user has no server to do it; it never touches the folder.
75
76
  - **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
76
77
  - **`AIWORKER_DATA_DIR` moves this directory too.** Every AiWorker folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
@@ -1,7 +1,7 @@
1
1
  import { DateTime, IANAZone } from 'luxon';
2
2
  import parser from 'cron-parser';
3
3
 
4
- import { projectPathFromId } from './project-id.js';
4
+ import { projectConfigFileStemOf, projectPathFromId } from './project-id.js';
5
5
  import {
6
6
  DEFAULT_PLANS_DIR,
7
7
  EMPTY_SHARED_PROJECT_CONFIG,
@@ -399,6 +399,15 @@ export const createProjectConfigRuntime = (deps) => {
399
399
 
400
400
  const resolveProjectConfigPath = (projectID) => {
401
401
  const safeProjectID = sanitizeProjectID(projectID);
402
+ return path.join(projectsDirPath, `${projectConfigFileStemOf(safeProjectID)}.json`);
403
+ };
404
+
405
+ // Where a build before the bounded file name stored an id too long for one
406
+ // (on a filesystem that still accepted it), or null when the id's own name
407
+ // is the current one. Read as a fallback, moved on the next write.
408
+ const resolveLegacyProjectConfigPath = (projectID) => {
409
+ const safeProjectID = sanitizeProjectID(projectID);
410
+ if (projectConfigFileStemOf(safeProjectID) === safeProjectID) return null;
402
411
  return path.join(projectsDirPath, `${safeProjectID}.json`);
403
412
  };
404
413
 
@@ -500,18 +509,31 @@ export const createProjectConfigRuntime = (deps) => {
500
509
  throw new Error(`timeout acquiring project config lock for ${projectID}`);
501
510
  };
502
511
 
503
- const readRawProjectConfigFromDisk = async (projectID) => {
504
- const filePath = resolveProjectConfigPath(projectID);
512
+ // The parsed document, or null when there is no file (one of
513
+ // `missingCodes`). Malformed JSON still throws; it is a broken file, not
514
+ // an empty one.
515
+ const readJsonDocumentIfPresent = async (filePath, missingCodes) => {
516
+ let raw;
505
517
  try {
506
- const raw = await fsPromises.readFile(filePath, 'utf8');
507
- const parsed = JSON.parse(raw);
508
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
518
+ raw = await fsPromises.readFile(filePath, 'utf8');
509
519
  } catch (error) {
510
- if (error && typeof error === 'object' && error.code === 'ENOENT') {
511
- return {};
512
- }
520
+ const code = error && typeof error === 'object' ? error.code : null;
521
+ if (missingCodes.includes(code)) return null;
513
522
  throw error;
514
523
  }
524
+ const parsed = JSON.parse(raw);
525
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
526
+ };
527
+
528
+ // The legacy name of a long id may exceed what the filesystem can hold;
529
+ // ENAMETOOLONG there means "no such file". On the bounded path it would
530
+ // mean the projects dir itself is unusable, so it stays an error.
531
+ const readRawProjectConfigFromDisk = async (projectID) => {
532
+ const current = await readJsonDocumentIfPresent(resolveProjectConfigPath(projectID), ['ENOENT']);
533
+ if (current) return current;
534
+ const legacyPath = resolveLegacyProjectConfigPath(projectID);
535
+ const legacy = legacyPath ? await readJsonDocumentIfPresent(legacyPath, ['ENOENT', 'ENAMETOOLONG']) : null;
536
+ return legacy ?? {};
515
537
  };
516
538
 
517
539
  // Normalized tasks for reading, plus the raw on-disk record of each one for
@@ -578,6 +600,13 @@ export const createProjectConfigRuntime = (deps) => {
578
600
  await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
579
601
  throw error;
580
602
  }
603
+ // Every write goes through a read of the whole document, so the legacy
604
+ // file's content is now in the current one; drop it so a later read (or an
605
+ // older build) cannot see two copies drifting apart.
606
+ const legacyPath = resolveLegacyProjectConfigPath(projectID);
607
+ if (legacyPath) {
608
+ await fsPromises.rm(legacyPath, { force: true }).catch(() => {});
609
+ }
581
610
  };
582
611
 
583
612
  const writeProjectConfigToDisk = async (projectID, config) => {
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
5
5
  import { createProjectConfigRuntime } from './project-config.js';
6
+ import { createProjectIdFromPath } from './project-id.js';
6
7
 
7
8
  const createRuntime = async () => {
8
9
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
@@ -175,6 +176,102 @@ describe('project-config runtime', () => {
175
176
  });
176
177
  });
177
178
 
179
+ describe('project-config file naming', () => {
180
+ const nightly = {
181
+ name: 'nightly',
182
+ enabled: true,
183
+ schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
184
+ execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
185
+ };
186
+
187
+ it('names the file by the id while the id fits a file name', async () => {
188
+ const { runtime, tempRoot, cleanup } = await createRuntime();
189
+ try {
190
+ const projectID = createProjectIdFromPath('/Users/someone/projects/demo');
191
+ expect(runtime.resolveProjectConfigPath(projectID)).toBe(path.join(tempRoot, `${projectID}.json`));
192
+ } finally {
193
+ await cleanup();
194
+ }
195
+ });
196
+
197
+ it('stores a project whose path is too long for a file name under a bounded name', async () => {
198
+ const { runtime, tempRoot, cleanup } = await createRuntime();
199
+ try {
200
+ // ~190 characters, like a scratchpad checkout nested under a worktree;
201
+ // the base64url id is longer than the 255-byte file name limit allows.
202
+ const projectPath = `/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`;
203
+ expect(projectPath.length).toBeGreaterThan(180);
204
+ const projectID = createProjectIdFromPath(projectPath);
205
+ expect(projectID.length).toBeGreaterThan(240);
206
+
207
+ const filePath = runtime.resolveProjectConfigPath(projectID);
208
+ const fileName = path.basename(filePath);
209
+ expect(path.dirname(filePath)).toBe(tempRoot);
210
+ expect(fileName.startsWith('path_sha256_')).toBe(true);
211
+ expect(fileName.length).toBeLessThan(100);
212
+ // The same id always maps to the same file.
213
+ expect(runtime.resolveProjectConfigPath(projectID)).toBe(filePath);
214
+
215
+ // The whole write path (lock file, temp file, rename) works on a real filesystem.
216
+ const created = await runtime.upsertScheduledTask(projectID, nightly);
217
+ expect(created.created).toBe(true);
218
+ const setup = await runtime.updateProjectSetup(projectID, { setupWorktree: ['bun install'] });
219
+ expect(setup.setupWorktree).toEqual(['bun install']);
220
+
221
+ const raw = JSON.parse(await readFile(filePath, 'utf8'));
222
+ expect(raw.scheduledTasks).toHaveLength(1);
223
+ expect(raw['setup-worktree']).toEqual(['bun install']);
224
+ expect(await runtime.listScheduledTasks(projectID)).toHaveLength(1);
225
+ expect((await runtime.readProjectSetup(projectID)).setupWorktree).toEqual(['bun install']);
226
+ } finally {
227
+ await cleanup();
228
+ }
229
+ });
230
+
231
+ it('reads a long id from its pre-bound file name and moves it on the next write', async () => {
232
+ const { runtime, tempRoot, cleanup } = await createRuntime();
233
+ try {
234
+ // Long enough for the bounded name, short enough that an older build
235
+ // could still have written `<id>.json` on this filesystem.
236
+ const projectID = `path_${'a'.repeat(200)}`;
237
+ const legacyPath = path.join(tempRoot, `${projectID}.json`);
238
+ const currentPath = runtime.resolveProjectConfigPath(projectID);
239
+ expect(currentPath).not.toBe(legacyPath);
240
+ await writeFile(legacyPath, JSON.stringify({
241
+ version: 1,
242
+ scheduledTasks: [{ id: 'task-legacy', ...nightly, state: { createdAt: 1, updatedAt: 2, lastStatus: 'idle' } }],
243
+ projectNotes: 'keep me',
244
+ }), 'utf8');
245
+
246
+ const tasks = await runtime.listScheduledTasks(projectID);
247
+ expect(tasks.map((task) => task.id)).toEqual(['task-legacy']);
248
+
249
+ await runtime.upsertScheduledTask(projectID, { ...nightly, name: 'weekly digest' });
250
+
251
+ const raw = JSON.parse(await readFile(currentPath, 'utf8'));
252
+ expect(raw.scheduledTasks.map((task) => task.id)).toEqual(['task-legacy', 'task-fixed-id']);
253
+ expect(raw.projectNotes).toBe('keep me');
254
+ await expect(readFile(legacyPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
255
+ expect((await runtime.listScheduledTasks(projectID)).map((task) => task.name)).toEqual(['nightly', 'weekly digest']);
256
+ } finally {
257
+ await cleanup();
258
+ }
259
+ });
260
+
261
+ it('treats a malformed pre-bound file as a failure, not as an empty project', async () => {
262
+ const { runtime, tempRoot, cleanup } = await createRuntime();
263
+ try {
264
+ const projectID = `path_${'b'.repeat(200)}`;
265
+ await writeFile(path.join(tempRoot, `${projectID}.json`), '{ not json', 'utf8');
266
+ await expect(runtime.listScheduledTasks(projectID)).rejects.toThrow();
267
+ await expect(runtime.upsertScheduledTask(projectID, nightly)).rejects.toThrow();
268
+ await expect(readFile(runtime.resolveProjectConfigPath(projectID), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
269
+ } finally {
270
+ await cleanup();
271
+ }
272
+ });
273
+ });
274
+
178
275
  describe('project-config loop reconciliation', () => {
179
276
  const loop = (name, overrides = {}) => ({
180
277
  scope: 'project',
@@ -1,3 +1,11 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ // The longest stem `<stem>.json` may have and still leave room, inside the
4
+ // 255-byte file name limit, for the atomic-write `.tmp-<pid>-<ms>-<random>`
5
+ // suffix and the `.json.lock` sibling. Ids are ASCII, so characters are bytes.
6
+ const MAX_PROJECT_CONFIG_FILE_STEM_LENGTH = 200;
7
+ const HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX = 'path_sha256_';
8
+
1
9
  const normalizeProjectPathForId = (value) => {
2
10
  if (typeof value !== 'string') return '';
3
11
  return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
@@ -28,3 +36,21 @@ export const projectPathFromId = (projectId) => {
28
36
  return '';
29
37
  }
30
38
  };
39
+
40
+ /**
41
+ * The stem of everything a project owns in the projects dir: the per-user
42
+ * config file `<projectsDir>/<stem>.json` and the sibling folder
43
+ * `<projectsDir>/<stem>/` (context, plans, memory). It is the id itself while
44
+ * that fits a file name. A `path_<base64url>` id grows with the checkout path,
45
+ * so a deeply nested project would otherwise get a name the filesystem
46
+ * rejects (ENAMETOOLONG); such an id maps to a fixed-length digest instead.
47
+ * The digest keeps the `path_` prefix so the orphan recovery in
48
+ * `opencode/settings-runtime.js` never mistakes the file for a leftover of the
49
+ * random-id era. The VS Code extension host applies the same rule
50
+ * (`packages/vscode/src/bridge-project-setup-runtime.ts`); keep the two in sync.
51
+ */
52
+ export const projectConfigFileStemOf = (projectId) => {
53
+ if (projectId.length <= MAX_PROJECT_CONFIG_FILE_STEM_LENGTH) return projectId;
54
+ const digest = crypto.createHash('sha256').update(projectId, 'utf8').digest('hex');
55
+ return `${HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX}${digest}`;
56
+ };
@@ -97,6 +97,26 @@ It does not replace a physical iOS/LTE check.
97
97
 
98
98
  ## Authentication model
99
99
 
100
+ ### Self-reported app diagnostics
101
+
102
+ Host-control, host-data and client upgrades include optional `appId`,
103
+ `appVersion` and `platform` query parameters. AiWorker sends `aiworker`,
104
+ the local package version and the local runtime. Client platforms use the
105
+ existing presence values: `desktop` for Electron, `vscode`, `ios`, `android`
106
+ and `web` for browsers, including hosted mobile. Hosts use
107
+ `AIWORKER_RUNTIME`, defaulting to `web`, so SSH hosts report `ssh-remote`.
108
+ These labels describe the connecting process, not its remote target.
109
+
110
+ These fields are diagnostics only, outside the signed auth payload. Older
111
+ relays ignore them and older apps remain connectable. The hosted relay groups
112
+ missing or invalid fields as `unknown`. Its analytics count host-control and
113
+ client connections separately, including reconnects, and show labels as
114
+ self-reported, never as verified app identity. Host-data sockets are excluded.
115
+ App diagnostics are written on connection, without per-app traffic counters.
116
+ No credentials, device names or application contents are added to diagnostics.
117
+
118
+ ### Access checks
119
+
100
120
  - The tunnel is **transport only**. The AiWorker server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
101
121
  - Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
102
122
  - The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
@@ -4,6 +4,7 @@
4
4
  // dispatcher. Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 1).
5
5
 
6
6
  import { WebSocket } from 'ws';
7
+ import { createRequire } from 'node:module';
7
8
 
8
9
  import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
9
10
  import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
@@ -50,6 +51,8 @@ const resolveBatchWindowMs = (option) => {
50
51
  * }} options
51
52
  */
52
53
  export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
54
+ const { version } = createRequire(import.meta.url)('../../../package.json');
55
+ const platform = process.env.AIWORKER_RUNTIME || 'web';
53
56
  const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
54
57
  const localBatch = batch !== false;
55
58
  const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
@@ -82,6 +85,10 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
82
85
  url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
83
86
  url.searchParams.set('role', role);
84
87
  url.searchParams.set('serverId', identity.serverId);
88
+ // Self-reported diagnostics, not part of relay authentication.
89
+ url.searchParams.set('appId', 'aiworker');
90
+ url.searchParams.set('appVersion', version);
91
+ url.searchParams.set('platform', platform);
85
92
  if (connectionId) url.searchParams.set('connectionId', connectionId);
86
93
  const auth = identity.signRelayAuth(role, connectionId ?? null);
87
94
  url.searchParams.set('ts', String(auth.ts));
@@ -39,11 +39,17 @@ const startFakeRelay = () => {
39
39
  clients: new Map(), // connectionId -> ws
40
40
  buffered: new Map(), // connectionId -> [[data, isBinary]] awaiting host-data
41
41
  relayFrames: [], // observed forwarded frames (for plaintext assertions)
42
+ apps: new Map(),
42
43
  };
43
44
 
44
45
  wss.on('connection', (ws, req) => {
45
46
  const url = new URL(req.url, 'http://localhost');
46
47
  const role = url.searchParams.get('role');
48
+ state.apps.set(role, {
49
+ appId: url.searchParams.get('appId'),
50
+ appVersion: url.searchParams.get('appVersion'),
51
+ platform: url.searchParams.get('platform'),
52
+ });
47
53
  const connectionId = url.searchParams.get('connectionId');
48
54
 
49
55
  if (role === 'host-control') {
@@ -101,6 +107,7 @@ const startFakeRelay = () => {
101
107
  // A socket a failed test left open would hold server.close() until
102
108
  // the hook timeout; drop them so a failure is reported once.
103
109
  for (const client of wss.clients) client.terminate();
110
+ server.closeAllConnections();
104
111
  wss.close();
105
112
  server.close(() => r());
106
113
  }),
@@ -286,6 +293,13 @@ describe('relay host-client integration', () => {
286
293
  });
287
294
 
288
295
  expect(result.status).toBe(200);
296
+ for (const role of ['host-control', 'host-data']) {
297
+ expect(relay.state.apps.get(role)).toEqual({
298
+ appId: 'aiworker',
299
+ appVersion: expect.stringMatching(/^\d+\.\d+\.\d+/),
300
+ platform: process.env.AIWORKER_RUNTIME || 'web',
301
+ });
302
+ }
289
303
  expect(result.body.ok).toBe(true);
290
304
  expect(result.body.relayConn).toBe('conn-test-1');
291
305
  expect(result.body.origin).toBe(`http://127.0.0.1:${origin.port}`);
@@ -25,6 +25,12 @@ in shared project config under the project write lock:
25
25
  from the winner's persisted `nextRunAt`.
26
26
  - Project config writes also take a cross-process `.json.lock` file so the
27
27
  read-modify-write is serialized across processes, not only within one process.
28
+ - `syncAllProjects` (startup and every full resync) syncs each registered
29
+ project on its own: a project whose config cannot be read or written (a
30
+ broken file, a lock timeout) is logged with its id and skipped, and every
31
+ other project's tasks are still scheduled. Only `listProjects` failing
32
+ aborts the sync as a whole. `syncProject` for one project still throws, so
33
+ a route for that project reports the failure.
28
34
  - The sharing processes may run different AiWorker versions. Normalization
29
35
  keeps only the fields a build knows, so every writer persists tasks it did
30
36
  not change verbatim from disk and swaps only `state` onto a task whose state
@@ -63,7 +69,9 @@ in shared project config under the project write lock:
63
69
  - Claiming always writes `nextRunAt` (including `undefined`) so a past once-slot
64
70
  is cleared when there is no following occurrence.
65
71
 
66
- Manual `runNow` does not claim a schedule occurrence.
72
+ Manual `runNow` does not claim a schedule occurrence. It also runs paused
73
+ (`enabled: false`) tasks — that is the point of the button — while scheduled
74
+ dispatches still skip disabled tasks, and completion never re-arms a paused task.
67
75
 
68
76
  ## Files
69
77
 
@@ -485,4 +485,32 @@ describe('issue 2710: daily scheduled task double execution at the configured ti
485
485
 
486
486
  runtime.stop();
487
487
  });
488
+
489
+ it('manual runNow runs a paused task but does not schedule future runs', async () => {
490
+ vi.setSystemTime(UTC(2026, 0, 1, 14, 0, 0));
491
+ const task = { ...makeTask({ kind: 'daily', times: ['15:00'] }), enabled: false };
492
+ const projectConfigRuntime = createSharedProjectConfigRuntime(task);
493
+
494
+ const runtime = createScheduledTasksRuntime(createRuntimeDeps(projectConfigRuntime));
495
+ await runtime.start();
496
+
497
+ // The scheduler must not fire a disabled task on its own.
498
+ await vi.advanceTimersByTimeAsync(HOUR + 3_000);
499
+ expect(sdk.sessionCreates.length).toBe(0);
500
+
501
+ const manual = await runtime.runNow('p1', 'task-1');
502
+ expect(manual.ok).toBe(true);
503
+ expect(manual.sessionID).toBeTruthy();
504
+ expect(sdk.sessionCreates.length).toBe(1);
505
+ expect(runtime.getStatus().runningScheduledTasksCount).toBe(0);
506
+
507
+ // Completion records state but leaves the task paused with no next run.
508
+ const tasks = await projectConfigRuntime.listScheduledTasks('p1');
509
+ expect(tasks[0].enabled).toBe(false);
510
+ expect(tasks[0].state.lastStatus).toBe('success');
511
+ expect(tasks[0].state.lastSessionId).toBe('sess-1');
512
+ expect(tasks[0].state.nextRunAt).toBeUndefined();
513
+
514
+ runtime.stop();
515
+ });
488
516
  });
@@ -426,7 +426,16 @@ export const createScheduledTasksRuntime = (deps) => {
426
426
  }
427
427
 
428
428
  for (const projectID of activeProjectIDs) {
429
- await syncProject(projectID);
429
+ try {
430
+ await syncProject(projectID);
431
+ } catch (error) {
432
+ // One project's config being unusable (a broken file, a lock timeout)
433
+ // must not keep every other project's tasks from being scheduled.
434
+ logger.warn?.('[ScheduledTasks] failed to sync project', {
435
+ projectID,
436
+ error: error?.message ?? String(error),
437
+ });
438
+ }
430
439
  }
431
440
  };
432
441
 
@@ -676,7 +685,8 @@ export const createScheduledTasksRuntime = (deps) => {
676
685
  const runTask = async (projectID, taskID, reason, scheduledFor) => {
677
686
  const taskMap = tasksByProject.get(projectID);
678
687
  const task = taskMap?.get(taskID);
679
- if (!task || !task.enabled) {
688
+ // Manual runNow runs paused tasks too; only scheduled dispatches skip them.
689
+ if (!task || (reason !== 'manual' && !task.enabled)) {
680
690
  return { ok: false, skipped: true };
681
691
  }
682
692
 
@@ -206,3 +206,56 @@ Run daily.
206
206
  }
207
207
  });
208
208
  });
209
+
210
+ describe('scheduled-tasks runtime syncAllProjects', () => {
211
+ it('keeps scheduling the other projects when one project cannot be synced', async () => {
212
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-sync-all-'));
213
+ try {
214
+ const brokenPath = path.join(tempRoot, 'broken');
215
+ const healthyPath = path.join(tempRoot, 'healthy');
216
+ await mkdir(path.join(healthyPath, '.agents', 'loops'), { recursive: true });
217
+ await mkdir(brokenPath, { recursive: true });
218
+ await writeFile(path.join(healthyPath, '.agents', 'loops', 'daily.md'), `---
219
+ name: daily
220
+ schedule: "0 9 * * *"
221
+ enabled: true
222
+ model: openai/gpt-5
223
+ ---
224
+ Run daily.
225
+ `, 'utf8');
226
+
227
+ const projectConfigRuntime = createProjectConfigRuntime({
228
+ fsPromises: await import('fs/promises'),
229
+ path,
230
+ projectsDirPath: path.join(tempRoot, 'config'),
231
+ createTaskID: () => 'task-fixed-id',
232
+ });
233
+ await mkdir(path.join(tempRoot, 'config'), { recursive: true });
234
+ await writeFile(projectConfigRuntime.resolveProjectConfigPath('broken'), '{ not json', 'utf8');
235
+
236
+ const warnings = [];
237
+ const runtime = createScheduledTasksRuntime({
238
+ buildOpenCodeUrl: () => 'http://localhost',
239
+ getOpenCodeAuthHeaders: () => ({}),
240
+ waitForOpenCodeReady: async () => {},
241
+ projectConfigRuntime,
242
+ listProjects: async () => [
243
+ { id: 'broken', path: brokenPath },
244
+ { id: 'healthy', path: healthyPath },
245
+ ],
246
+ logger: { warn: (...args) => warnings.push(args) },
247
+ });
248
+
249
+ await expect(runtime.start()).resolves.toBeUndefined();
250
+
251
+ expect(runtime.getStatus().enabledScheduledTasksCount).toBe(1);
252
+ const healthyTasks = await projectConfigRuntime.listScheduledTasks('healthy');
253
+ expect(healthyTasks.map((task) => task.id)).toEqual(['loop:project:daily']);
254
+ expect(warnings).toHaveLength(1);
255
+ expect(warnings[0][1]).toMatchObject({ projectID: 'broken' });
256
+ runtime.stop();
257
+ } finally {
258
+ await rm(tempRoot, { recursive: true, force: true });
259
+ }
260
+ });
261
+ });