@openchambery/web 1.19.11-beta.2 → 1.19.12

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 (52) hide show
  1. package/dist/assets/{AssistantView-TGs8Wcs9.js → AssistantView-DxIghtun.js} +1 -1
  2. package/dist/assets/{DiagramView-C8uUID8q.js → DiagramView-BeUgp_Y0.js} +1 -1
  3. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +2 -0
  4. package/dist/assets/{MarkdownRendererImpl-NkxEMuNz.js → MarkdownRendererImpl-DB4Q3kGa.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-Cn8_l6dk.js → MarkstreamRendererImpl-BFdJzxia.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-SssWxXcs.js → MobileDetailNavigation-B_RwWOwv.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-B0DKNKCh.js → MobileShareBridge-UiOLx0-2.js} +1 -1
  8. package/dist/assets/MobileSurface-CNChj_dX.js +2 -0
  9. package/dist/assets/{MultiRunWindow-D4kFjP9X.js → MultiRunWindow-C3p3lj3U.js} +1 -1
  10. package/dist/assets/{SettingsView-ByeO7Wel.js → SettingsView-BXfKsz0J.js} +1 -1
  11. package/dist/assets/{SettingsWindow-DYKqHoRZ.js → SettingsWindow-8IDuPZA5.js} +1 -1
  12. package/dist/assets/{TerminalView-XTCK_K5c.js → TerminalView-BiKCueth.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-IYT8WBnx.js → ToolOutputDialog-BC2F_Fdl.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-Dd6sHdyA.js → appThemeRegistry-B87OLIgc.js} +1 -1
  15. package/dist/assets/{gitApi-DHYErlJq.js → gitApi-tTtHQVA3.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-OW13CPRV.js → insertionBoundaries-CCQpZ5zn.js} +1 -1
  17. package/dist/assets/{main-DNt5GyaJ.js → main-C_PUoT3E.js} +4 -4
  18. package/dist/assets/{main-DiHKszj8.js → main-Dkg31fRY.js} +2 -2
  19. package/dist/assets/{miniChat-BWyoq7CH.js → miniChat-C9QMj8T8.js} +2 -2
  20. package/dist/assets/{mobile-C8dAZOL4.js → mobile-Z_X7CbzB.js} +2 -2
  21. package/dist/assets/{multirun-BLALuhVO.js → multirun-B0V3hdnD.js} +1 -1
  22. package/dist/assets/{projectMeta-D5rcTirG.js → projectMeta-DRW9MQ4z.js} +1 -1
  23. package/dist/assets/{renderElectronMiniChatApp-BjSXwHz7.js → renderElectronMiniChatApp-Dn1Su1dd.js} +2 -2
  24. package/dist/assets/{renderMobileApp-CdFTEeDF.js → renderMobileApp-BG1JJt1Q.js} +4 -4
  25. package/dist/assets/{runtimeConfig-CBABjfrt.js → runtimeConfig-C6matIEB.js} +1 -1
  26. package/dist/assets/{runtimeEndpointReset-DGlvVJIx.js → runtimeEndpointReset-kgwmUJDb.js} +1 -1
  27. package/dist/assets/{sessionLookup-sh24_saP.js → sessionLookup-B6P_aCun.js} +1 -1
  28. package/dist/assets/{useAppFontEffects-CSJXvjOD.js → useAppFontEffects-Dmes1chr.js} +5 -5
  29. package/dist/assets/{useDesktopWindowControlsLayout-BXjz31fp.js → useDesktopWindowControlsLayout-DuZ6URPR.js} +1 -1
  30. package/dist/assets/{useEffectiveDirectory-BHzOapyb.js → useEffectiveDirectory-CU2ChP0J.js} +1 -1
  31. package/dist/assets/{useMobileNavigationStore-DuFaJ45K.js → useMobileNavigationStore-C8_J-qTp.js} +1 -1
  32. package/dist/assets/{useSessionAutoCleanup-C4RQX7Vw.js → useSessionAutoCleanup-FOQeu1_T.js} +1 -1
  33. package/dist/assets/{useWorkerHighlightedLines-CsUd8Exs.js → useWorkerHighlightedLines-GBqKnki1.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/dist/mini-chat.html +2 -2
  36. package/dist/mobile.html +2 -2
  37. package/package.json +1 -1
  38. package/server/lib/assistants/DOCUMENTATION.md +5 -3
  39. package/server/lib/assistants/contact-tools.js +114 -22
  40. package/server/lib/assistants/contact-tools.test.js +56 -0
  41. package/server/lib/assistants/harness.js +17 -3
  42. package/server/lib/assistants/harness.test.js +120 -1
  43. package/server/lib/assistants/pi-tools.js +50 -3
  44. package/server/lib/assistants/pi-tools.test.js +25 -0
  45. package/server/lib/assistants/service.test.js +43 -0
  46. package/server/lib/llm/DOCUMENTATION.md +31 -18
  47. package/server/lib/llm/generate.js +43 -7
  48. package/server/lib/llm/generate.test.js +98 -1
  49. package/server/lib/llm/temp-directory.js +34 -13
  50. package/server/lib/llm/temp-directory.test.js +57 -0
  51. package/dist/assets/MarkdownRenderer-C-MQ71aj.js +0 -2
  52. package/dist/assets/MobileSurface-i9r-v3E3.js +0 -2
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest';
5
5
  import {
6
6
  collectAssistantSkillDirectories,
7
7
  createPiCodingRuntime,
8
+ formatPiCodingPrompt,
8
9
  mergeSkillsByName,
9
10
  PI_CODING_TOOL_NAMES,
10
11
  resolveAssistantCwd,
@@ -78,6 +79,30 @@ describe('mergeSkillsByName', () => {
78
79
  });
79
80
  });
80
81
 
82
+ describe('formatPiCodingPrompt', () => {
83
+ it('includes full pi tool argument schemas and distinguishes skills from tools', async () => {
84
+ const cwd = temp();
85
+ const home = temp();
86
+ const runtime = await createPiCodingRuntime(cwd, { homeDir: home });
87
+ const prompt = formatPiCodingPrompt({
88
+ cwd: runtime.cwd,
89
+ skillsPrompt: runtime.skillsPrompt,
90
+ tools: runtime.tools,
91
+ });
92
+ expect(prompt).toContain('arguments schema:');
93
+ expect(prompt).toContain('"path"');
94
+ expect(prompt).toContain('"content"');
95
+ expect(prompt).toContain('"edits"');
96
+ expect(prompt).toContain('"oldText"');
97
+ expect(prompt).toContain('"newText"');
98
+ expect(prompt).toContain('"command"');
99
+ expect(prompt).toContain('not OpenCode native tools and not MCP');
100
+ expect(prompt).toContain('never invent skill or MCP tool names');
101
+ expect(prompt).toContain('context7');
102
+ await runtime.close();
103
+ });
104
+ });
105
+
81
106
  describe('createPiCodingRuntime', () => {
82
107
  it('installs read/write/edit/bash and merges global plus project skills', async () => {
83
108
  const home = temp();
@@ -1160,6 +1160,49 @@ describe('assistants service', () => {
1160
1160
  service.close();
1161
1161
  });
1162
1162
 
1163
+ it('same contact-turn assign gate: model re-send creates one worker and one card', async () => {
1164
+ const directory = root();
1165
+ const project = path.join(directory, 'app');
1166
+ fs.mkdirSync(project, { recursive: true });
1167
+ const creates = [];
1168
+ const service = setup(directory, {
1169
+ create: async (input) => {
1170
+ creates.push(input);
1171
+ return { data: { id: `ses_${creates.length}` } };
1172
+ },
1173
+ promptAsync: async () => ({ response: { status: 204 } }),
1174
+ }, {
1175
+ runContactTurn: async ({ tools, userText }) => {
1176
+ const assign = tools.find((tool) => tool.name === 'assign_session');
1177
+ const args = { prompt: userText, projectPath: project, title: 'Once' };
1178
+ const first = await assign.execute('call_1', args);
1179
+ const second = await assign.execute('call_2', args);
1180
+ const thirdDifferent = await assign.execute('call_3', { ...args, prompt: `${userText} again` });
1181
+ expect(first.terminate).toBe(true);
1182
+ expect(second.terminate).toBe(true);
1183
+ expect(second.details.assigned.sessionID).toBe(first.details.assigned.sessionID);
1184
+ expect(thirdDifferent.details.error).toBe('validation_error');
1185
+ return {
1186
+ text: first.content[0].text,
1187
+ bubbles: [first.content[0].text],
1188
+ cards: first.details.card ? [first.details.card, second.details.card].filter(Boolean) : [],
1189
+ tools,
1190
+ };
1191
+ },
1192
+ });
1193
+ const assistant = service.createAssistant(assistantInput);
1194
+ await settleSend(service, assistant.id, {
1195
+ messageID: 'client_assign_once',
1196
+ parts: [{ type: 'text', text: 'Fix once' }],
1197
+ });
1198
+ expect(creates).toHaveLength(1);
1199
+ const page = service.contactMessages(assistant.id);
1200
+ const cards = page.messages.flatMap((message) => (message.parts || []).filter((part) => part.type === 'card'));
1201
+ expect(cards).toHaveLength(1);
1202
+ expect(cards[0]).toMatchObject({ cardType: 'session', sessionID: 'ses_1' });
1203
+ service.close();
1204
+ });
1205
+
1163
1206
  it('creates another assistant from create_assistant and persists the assistant card', async () => {
1164
1207
  const directory = root();
1165
1208
  const service = setup(directory, {}, {
@@ -29,27 +29,40 @@ The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
29
29
  the response is JSON (`Content-Type` or a JSON object body). SPA / OpenCode
30
30
  HTML `200 <!doctype` is not generate — fall through to the throwaway path.
31
31
  Bundled 1.18.4 has no sessionless generate.
32
- 2. Otherwise create a throwaway archived OpenCode session, deny every tool
33
- (`client.tool.ids()` `{ [id]: false }`), send our messages as
34
- `system` + user text via `session.promptAsync` (v2 `session.prompt` only
35
- forwards `{ id, prompt, delivery, resume }` and drops `model`/`parts`
36
- that produced empty assistant text and a 502). Contact file parts reuse the
37
- existing OpenCode `{ type: 'file', mime, url, filename? }` delivery shape
38
- (data URLs in the contact SQLite store not a second attachment store) and
39
- are forwarded on `promptAsync` only when the connected catalog marks that
40
- model as image-capable (`modalities.input`, `input`, or `attachment`).
41
- Non-vision models (for example deepseek-v4-flash) keep the `[image: …]`
42
- description and any text-file bytes, and skip image data URLs so generate
43
- cannot stall on unsupported vision parts. Non-image
44
- text files are also inlined into the flattened prompt. Wait for idle via
45
- `session.status` + `session.messages`, then delete the session. Throwaway
46
- sessions are created with
47
- `metadata.openchamber.llm.purpose = 'chat-completions'` (and archived
48
- immediately) so sidebar visibility, session-index, session-title, and
49
- notification/push fanout treat them as system-owned same contract as
32
+ 2. Otherwise create a throwaway archived OpenCode session under a hidden
33
+ `openchamber-llm` agent whose frontmatter uses OpenCode 1.18
34
+ `permission: { "*": deny }` (not legacy `action`/`resource`/`effect` lists —
35
+ those load without a terminal `*` deny). `session.create` also sets
36
+ `permission: [{ permission: '*', pattern: '*', action: 'deny' }]` (SDK
37
+ PermissionRuleset; verified to round-trip on the session object). Deny every
38
+ catalog tool via `client.tool.ids()` `{ [id]: false }` on `promptAsync`;
39
+ a `tool.ids` SDK/transport error fails generate (never silent empty deny).
40
+ Send messages as `system` + user text via `session.promptAsync` (v2
41
+ `session.prompt` only forwards `{ id, prompt, delivery, resume }` and drops
42
+ `model`/`parts` that produced empty assistant text and a 502). Contact
43
+ file parts reuse the existing OpenCode `{ type: 'file', mime, url, filename? }`
44
+ delivery shape (data URLs in the contact SQLite store not a second
45
+ attachment store) and are forwarded on `promptAsync` only when the connected
46
+ catalog marks that model as image-capable (`modalities.input`, `input`, or
47
+ `attachment`). Non-vision models (for example deepseek-v4-flash) keep the
48
+ `[image: …]` description and any text-file bytes, and skip image data URLs so
49
+ generate cannot stall on unsupported vision parts. Non-image text files are
50
+ also inlined into the flattened prompt. Wait for idle via `session.status` +
51
+ `session.messages`; a deterministic `session.messages` SDK error (for example
52
+ HTTP 400) fails generate immediately. Then delete the session and surface
53
+ delete SDK errors in diagnostics without erasing a successful text result.
54
+ Throwaway sessions use `metadata.openchamber.llm.purpose = 'chat-completions'`
55
+ (and archived immediately) so sidebar visibility, session-index, session-title,
56
+ and notification/push fanout treat them as system-owned — same contract as
50
57
  non-empty `smallModel.purpose`. This is a text generator only — never the
51
58
  contact transcript and never a coding SessionPrompt loop. Upstream
52
59
  `info.error.message` is forwarded on 502.
60
+ `ensureLlmTempDirectory` keeps one process-wide throwaway root (`openchamber-llm-…`).
61
+ Concurrent first callers share a single `mkdtemp` inflight; every caller still
62
+ writes/refreshes `.opencode/agent/<name>.md` after settle so a mid-flight ensure
63
+ cannot skip a newer agent body. Concurrent warm refreshes serialize through one
64
+ write chain (last committed body wins; no interleaved partial writes).
65
+ `stopLlmTempDirectory` clears the singleton for shutdown/tests.
53
66
 
54
67
  ### Internal token callback (in-process only)
55
68
 
@@ -6,19 +6,34 @@ const SETTLE_POLL_MS = 250;
6
6
  const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
7
7
  const EMPTY_IDLE_PROBES = 5;
8
8
 
9
+ /**
10
+ * Session-level deny-all. OpenCode 1.18 session.create serializes this
11
+ * PermissionRuleset shape (permission/pattern/action). Confirmed on local
12
+ * 1.18.23/1.18.29: create returns the same rule on the session object.
13
+ */
14
+ export const LLM_SESSION_DENY_PERMISSION = Object.freeze([
15
+ Object.freeze({ permission: '*', pattern: '*', action: 'deny' }),
16
+ ]);
17
+
18
+ /**
19
+ * Agent frontmatter for OpenCode 1.18. Runtime /agent metadata uses
20
+ * permission:[{permission,pattern,action}]. Markdown config accepts the
21
+ * PermissionActionConfig map (`"*": deny`) / scalar `deny` / tools:"*":false —
22
+ * not the legacy action/resource/effect list (that loads but does not add
23
+ * a terminal * deny, so build-like allows and skill guidance stay visible).
24
+ */
9
25
  const AGENT_MARKDOWN = `---
10
26
  mode: primary
11
27
  hidden: true
12
- permissions:
13
- - action: "*"
14
- resource: "*"
15
- effect: deny
28
+ permission:
29
+ "*": deny
16
30
  ---
17
31
 
18
32
  You generate responses for an application-owned assistant. Follow the supplied system instructions and response format.
19
33
  The application executes its registered tools, including openchamber-tool JSON fences in your response, and supplies their results on the next request.
20
34
  Emit the requested application tool call when an action requires one. Your native tool permissions describe this generator process; the application's supplied tool catalog describes the assistant's capabilities.
21
35
  Report execution and failures from supplied tool results only.
36
+ Do not call native OpenCode tools, MCP tools, or skill tools. Native permissions are denied for this generator session.
22
37
  `;
23
38
 
24
39
  const isMissing = (result) =>
@@ -152,7 +167,11 @@ const waitForIdleAssistant = async ({ client, sessionID, directory, signal }) =>
152
167
  directory,
153
168
  limit: 20,
154
169
  }, { signal });
155
- if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
170
+ if (messagesResult?.error) {
171
+ // Deterministic upstream failures (e.g. 400) must not look like "still settling".
172
+ failGenerate(`OpenCode LLM session.messages failed: ${sdkErrorMessage(messagesResult, 'messages failed')}`);
173
+ }
174
+ if (Array.isArray(messagesResult?.data)) {
156
175
  const lastInfo = readMessageInfo(messagesResult.data.at(-1));
157
176
  if (lastInfo?.role === 'assistant') {
158
177
  emptyIdleProbes = 0;
@@ -469,13 +488,23 @@ export async function generateOpenCodeText({
469
488
  ? clientFactory()
470
489
  : createOpencodeClient({ baseUrl, directory: workingDirectory, headers });
471
490
 
472
- const toolIds = await client.tool.ids({ directory: workingDirectory }).catch(() => ({ data: [] }));
491
+ let toolIds;
492
+ try {
493
+ toolIds = await client.tool.ids({ directory: workingDirectory });
494
+ } catch (error) {
495
+ failGenerate(`OpenCode tool.ids failed: ${error?.message || 'tool.ids failed'}`);
496
+ }
497
+ if (toolIds?.error) {
498
+ failGenerate(`OpenCode tool.ids failed: ${sdkErrorMessage(toolIds, 'tool.ids failed')}`);
499
+ }
473
500
  const tools = deniedTools(Array.isArray(toolIds?.data) ? toolIds.data : []);
474
501
 
475
502
  const created = await client.session.create({
476
503
  directory: workingDirectory,
477
504
  title: '[openchamber-llm] generate',
478
505
  agent: LLM_AGENT_NAME,
506
+ // Session fence: deny every native OpenCode/MCP/skill tool for this throwaway.
507
+ permission: LLM_SESSION_DENY_PERMISSION,
479
508
  metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
480
509
  }, { signal: controller.signal });
481
510
  const sessionID = created?.data?.id;
@@ -548,7 +577,13 @@ export async function generateOpenCodeText({
548
577
  }
549
578
  unsubscribeDeltas = null;
550
579
  try {
551
- await client.session.delete({ sessionID, directory: workingDirectory });
580
+ const deleted = await client.session.delete({ sessionID, directory: workingDirectory });
581
+ if (deleted?.error) {
582
+ console.warn(
583
+ '[llm] failed to delete throwaway OpenCode session:',
584
+ sdkErrorMessage(deleted, 'delete failed'),
585
+ );
586
+ }
552
587
  } catch (error) {
553
588
  console.warn('[llm] failed to delete throwaway OpenCode session:', error?.message || error);
554
589
  }
@@ -570,4 +605,5 @@ export const _test = {
570
605
  subscribeThrowawayTextDeltas,
571
606
  LLM_AGENT_NAME,
572
607
  AGENT_MARKDOWN,
608
+ LLM_SESSION_DENY_PERMISSION,
573
609
  };
@@ -74,8 +74,11 @@ describe('generateOpenCodeText', () => {
74
74
  clientFactory: createOpencodeClient,
75
75
  ensureTempDirectory: async ({ agentMarkdown }) => {
76
76
  expect(agentMarkdown).toContain('openchamber-tool JSON fences')
77
- expect(agentMarkdown).toContain('effect: deny')
77
+ expect(agentMarkdown).toMatch(/"\*"\s*:\s*deny/)
78
+ expect(agentMarkdown).toContain('permission:')
79
+ expect(agentMarkdown).not.toContain('effect: deny')
78
80
  expect(agentMarkdown).toContain('Report execution and failures from supplied tool results only.')
81
+ expect(agentMarkdown).toContain('Native permissions are denied')
79
82
  return '/tmp/openchamber-llm'
80
83
  },
81
84
  detect: async () => ({ available: false, mode: 'throwaway-session' }),
@@ -83,6 +86,8 @@ describe('generateOpenCodeText', () => {
83
86
 
84
87
  expect(create).toHaveBeenCalledWith(expect.objectContaining({
85
88
  title: expect.stringContaining('[openchamber-llm]'),
89
+ agent: 'openchamber-llm',
90
+ permission: [{ permission: '*', pattern: '*', action: 'deny' }],
86
91
  metadata: { openchamber: { llm: { purpose: 'chat-completions' } } },
87
92
  }), expect.anything())
88
93
  expect(update).toHaveBeenCalled()
@@ -417,6 +422,98 @@ describe('generateOpenCodeText', () => {
417
422
  expect(subscribers.size).toBe(0)
418
423
  })
419
424
 
425
+ it('throws when tool.ids returns an SDK error instead of denying nothing', async () => {
426
+ const remove = vi.fn(async () => ({ data: true }))
427
+ await expect(generateOpenCodeText({
428
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
429
+ getOpenCodeAuthHeaders: () => ({}),
430
+ providerID: 'opencode',
431
+ modelID: 'gpt-5-nano',
432
+ messages: [{ role: 'user', content: 'hi' }],
433
+ clientFactory: () => ({
434
+ session: {
435
+ create: vi.fn(async () => ({ data: { id: 'ses_tmp' } })),
436
+ update: vi.fn(),
437
+ prompt: vi.fn(),
438
+ promptAsync: vi.fn(),
439
+ status: vi.fn(),
440
+ messages: vi.fn(),
441
+ delete: remove,
442
+ },
443
+ tool: { ids: async () => ({ error: { status: 500, message: 'tool catalog unavailable' } }) },
444
+ }),
445
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
446
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
447
+ })).rejects.toMatchObject({
448
+ code: 'upstream_error',
449
+ message: expect.stringContaining('tool.ids failed'),
450
+ })
451
+ expect(remove).not.toHaveBeenCalled()
452
+ })
453
+
454
+ it('throws when session.messages returns a deterministic 400', async () => {
455
+ const remove = vi.fn(async () => ({ data: true }))
456
+ await expect(generateOpenCodeText({
457
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
458
+ getOpenCodeAuthHeaders: () => ({}),
459
+ providerID: 'opencode',
460
+ modelID: 'gpt-5-nano',
461
+ messages: [{ role: 'user', content: 'hi' }],
462
+ clientFactory: () => ({
463
+ session: {
464
+ create: async () => ({ data: { id: 'ses_tmp' } }),
465
+ update: async () => ({ data: { id: 'ses_tmp' } }),
466
+ prompt: vi.fn(),
467
+ promptAsync: async () => ({ response: { status: 204 } }),
468
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
469
+ messages: async () => ({ error: { status: 400, message: 'OutputFormatJsonSchema' } }),
470
+ delete: remove,
471
+ },
472
+ tool: { ids: async () => ({ data: ['bash'] }) },
473
+ }),
474
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
475
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
476
+ })).rejects.toMatchObject({
477
+ code: 'upstream_error',
478
+ message: expect.stringMatching(/session\.messages failed.*400|OutputFormatJsonSchema/),
479
+ })
480
+ expect(remove).toHaveBeenCalled()
481
+ })
482
+
483
+ it('logs SDK delete errors without erasing a successful generate result', async () => {
484
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
485
+ try {
486
+ const result = await generateOpenCodeText({
487
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096',
488
+ getOpenCodeAuthHeaders: () => ({}),
489
+ providerID: 'opencode',
490
+ modelID: 'gpt-5-nano',
491
+ messages: [{ role: 'user', content: 'hi' }],
492
+ clientFactory: () => ({
493
+ session: {
494
+ create: async () => ({ data: { id: 'ses_tmp' } }),
495
+ update: async () => ({ data: { id: 'ses_tmp' } }),
496
+ prompt: vi.fn(),
497
+ promptAsync: async () => ({ response: { status: 204 } }),
498
+ status: async () => ({ data: { ses_tmp: { type: 'idle' } } }),
499
+ messages: async () => ({ data: [completedAssistant('ok')] }),
500
+ delete: async () => ({ error: { status: 500, message: 'delete denied' } }),
501
+ },
502
+ tool: { ids: async () => ({ data: [] }) },
503
+ }),
504
+ ensureTempDirectory: async () => '/tmp/openchamber-llm',
505
+ detect: async () => ({ available: false, mode: 'throwaway-session' }),
506
+ })
507
+ expect(result).toEqual({ text: 'ok', source: 'throwaway-session' })
508
+ expect(warn).toHaveBeenCalledWith(
509
+ expect.stringContaining('[llm] failed to delete throwaway OpenCode session:'),
510
+ expect.stringContaining('delete denied'),
511
+ )
512
+ } finally {
513
+ warn.mockRestore()
514
+ }
515
+ })
516
+
420
517
  it('does not invent deltas on the sessionless /generate JSON path', async () => {
421
518
  const onTextDelta = vi.fn()
422
519
  const subscribers = new Set()
@@ -6,28 +6,49 @@ import path from 'node:path';
6
6
  let tempDirectory = null;
7
7
  /** @type {Promise<string> | null} */
8
8
  let inflight = null;
9
+ /** Serialize markdown refreshes so concurrent ensure cannot interleave one file. */
10
+ let writeChain = Promise.resolve();
11
+
12
+ const writeAgentMarkdown = async (root, agentName, agentMarkdown) => {
13
+ const agentDir = path.join(root, '.opencode', 'agent');
14
+ await fs.mkdir(agentDir, { recursive: true });
15
+ await fs.writeFile(path.join(agentDir, `${agentName}.md`), agentMarkdown, 'utf8');
16
+ };
17
+
18
+ const enqueueWrite = (work) => {
19
+ const run = writeChain.catch(() => {}).then(work);
20
+ writeChain = run.catch(() => {});
21
+ return run;
22
+ };
9
23
 
10
24
  export async function ensureLlmTempDirectory({ agentName, agentMarkdown }) {
11
- if (tempDirectory) return tempDirectory;
12
- if (!inflight) {
13
- inflight = (async () => {
14
- const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-llm-'));
15
- const agentDir = path.join(root, '.opencode', 'agent');
16
- await fs.mkdir(agentDir, { recursive: true });
17
- await fs.writeFile(path.join(agentDir, `${agentName}.md`), agentMarkdown, 'utf8');
18
- tempDirectory = root;
19
- return root;
20
- })().finally(() => {
21
- inflight = null;
22
- });
25
+ // Long-lived servers / HMR must refresh the hidden agent markdown so a
26
+ // permission-format fix is not stuck behind a stale temp directory.
27
+ // Concurrent first callers share one mkdtemp; every caller still writes its
28
+ // markdown after settle so a mid-flight ensure cannot skip a newer body.
29
+ // Concurrent warm writes are serialized (last committed body wins).
30
+ if (!tempDirectory) {
31
+ if (!inflight) {
32
+ inflight = (async () => {
33
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-llm-'));
34
+ tempDirectory = root;
35
+ return root;
36
+ })().finally(() => {
37
+ inflight = null;
38
+ });
39
+ }
40
+ await inflight;
23
41
  }
24
- return inflight;
42
+ const root = tempDirectory;
43
+ await enqueueWrite(() => writeAgentMarkdown(root, agentName, agentMarkdown));
44
+ return root;
25
45
  }
26
46
 
27
47
  export async function stopLlmTempDirectory() {
28
48
  const dir = tempDirectory;
29
49
  tempDirectory = null;
30
50
  inflight = null;
51
+ writeChain = Promise.resolve();
31
52
  if (!dir) return;
32
53
  try {
33
54
  await fs.rm(dir, { recursive: true, force: true });
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import { ensureLlmTempDirectory, stopLlmTempDirectory } from './temp-directory.js';
5
+
6
+ const readAgent = async (root, agentName) =>
7
+ fs.readFile(path.join(root, '.opencode', 'agent', `${agentName}.md`), 'utf8');
8
+
9
+ afterEach(async () => {
10
+ await stopLlmTempDirectory();
11
+ });
12
+
13
+ describe('ensureLlmTempDirectory', () => {
14
+ it('shares one directory across concurrent first ensures and applies each markdown body', async () => {
15
+ const firstBody = '---\nname: a\n---\nFIRST\n';
16
+ const secondBody = '---\nname: a\n---\nSECOND\n';
17
+ const [first, second] = await Promise.all([
18
+ ensureLlmTempDirectory({ agentName: 'openchamber-llm', agentMarkdown: firstBody }),
19
+ ensureLlmTempDirectory({ agentName: 'openchamber-llm', agentMarkdown: secondBody }),
20
+ ]);
21
+ expect(first).toBe(second);
22
+ const written = await readAgent(first, 'openchamber-llm');
23
+ // Last writer wins; both callers must have completed a full write after settle.
24
+ expect(written === firstBody || written === secondBody).toBe(true);
25
+ expect(written).toMatch(/FIRST|SECOND/);
26
+ });
27
+
28
+ it('refreshes markdown on a warm directory under concurrent ensure', async () => {
29
+ const root = await ensureLlmTempDirectory({
30
+ agentName: 'openchamber-llm',
31
+ agentMarkdown: '---\nname: a\n---\nSEED\n',
32
+ });
33
+ const bodies = Array.from({ length: 8 }, (_, index) => `---\nname: a\n---\nREFRESH-${index}\n`);
34
+ const roots = await Promise.all(
35
+ bodies.map((agentMarkdown) => ensureLlmTempDirectory({ agentName: 'openchamber-llm', agentMarkdown })),
36
+ );
37
+ expect(new Set(roots)).toEqual(new Set([root]));
38
+ const written = await readAgent(root, 'openchamber-llm');
39
+ expect(bodies).toContain(written);
40
+ expect(written).toMatch(/REFRESH-\d+/);
41
+ });
42
+
43
+ it('stop clears the singleton so the next ensure creates a fresh directory', async () => {
44
+ const first = await ensureLlmTempDirectory({
45
+ agentName: 'openchamber-llm',
46
+ agentMarkdown: '---\nname: a\n---\nA\n',
47
+ });
48
+ await stopLlmTempDirectory();
49
+ const second = await ensureLlmTempDirectory({
50
+ agentName: 'openchamber-llm',
51
+ agentMarkdown: '---\nname: a\n---\nB\n',
52
+ });
53
+ expect(second).not.toBe(first);
54
+ await expect(fs.access(first)).rejects.toThrow();
55
+ expect(await readAgent(second, 'openchamber-llm')).toContain('B');
56
+ });
57
+ });
@@ -1,2 +0,0 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/MarkstreamRendererImpl-Cn8_l6dk.js","assets/rolldown-runtime-C0FnF6B9.js","assets/vendor-base-ui-CnHfUmDN.js","assets/vendor-react-DLPElpoQ.js","assets/vendor-markstream-react-DX55XLGO.js","assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js","assets/vendor-markstream-core-Cm34iPLL.js","assets/vendor-floating-ui-dom-BHhLtF5l.js","assets/vendor-floating-ui-core-Co1I1lFV.js","assets/vendor-reactuses-core-DDoDDyM9.js","assets/vendor-lodash-es-DfLbG5Af.js","assets/vendor-js-cookie-hSOiFTtX.js","assets/useMobileNavigationStore-DuFaJ45K.js","assets/useUIStore-CuaA8-Hp.js","assets/vendor-zustand-B4dlGEM2.js","assets/persistence-DdUac14I.js","assets/vendor-pathe-vBmvQE8f.js","assets/vendor-tanstack-query-core-BWz0ose6.js","assets/platform-B4KKLnZ5.js","assets/vendor-opencode-sdk-C07HQypa.js","assets/vendor-tanstack-react-query-D7pys_c9.js","assets/vendor-sonner-DVzioF3d.js","assets/clipboard-DJ4nQEuY.js","assets/store-BDFdnk6l.js","assets/useAssistantUIStore-me6s63b2.js","assets/vendor-fuse.js-DB5wDJbI.js","assets/utils-BiK3tBGM.js","assets/vendor-class-variance-authority-BLUaYARZ.js","assets/vendor-tailwind-merge-BicNWLTg.js","assets/useThemeSystem-DMzWVJaW.js","assets/useRuntimeAPIs-DSLXJybW.js","assets/url-Pk6tQ1EZ.js","assets/useEffectiveDirectory-BHzOapyb.js","assets/markdownHeightCache-CYDlisxr.js","assets/MarkdownRendererImpl-NkxEMuNz.js","assets/vendor-katex--B5dHUyk.js","assets/markdownTheme-DyJ4Gfcj.js","assets/vendor-pierre-diffs-DX7XTPG3.js","assets/vendor-hast-util-to-html-B0p1k5Xa.js","assets/vendor-character-entities-legacy-Wut6vMuj.js","assets/vendor-character-entities-html4-DlN_yAFd.js","assets/vendor-ccount-CyGsA0u7.js","assets/vendor-comma-separated-tokens-B9ouxNNS.js","assets/vendor-oniguruma-to-es-CrHzjjSU.js","assets/vendor-oniguruma-parser-D2DIvhSP.js","assets/vendor-diff-Dq7CZNim.js","assets/vendor-lru_map-DpDied8H.js","assets/markdown-worker-CTsrtpPh.js","assets/FadeInOnReveal-CTlNE4Hg.js","assets/vendor-morphdom-BbKiNDAu.js","assets/vendor-beautiful-mermaid-DykqIK5D.js","assets/vendor-dompurify-CeJNOzik.js","assets/vendor-marked-WVekxjAN.js","assets/vendor-remend-C3cXc-tf.js","assets/MarkstreamRendererImpl-DKhVy3nF.css"])))=>i.map(i=>d[i]);
2
- import{o as e}from"./rolldown-runtime-C0FnF6B9.js";import{a as t}from"./vendor-zustand-B4dlGEM2.js";import{nt as n,rt as r}from"./vendor-base-ui-CnHfUmDN.js";import{o as i}from"./vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js";import{n as a}from"./vendor-react-DLPElpoQ.js";import{t as o}from"./utils-BiK3tBGM.js";import{a as s,n as c,t as l}from"./markdownHeightCache-CYDlisxr.js";var u=a(),d=e(r(),1),f=`openchamber:chunk-import-reload`,p=250,m=3e4,h=[/Importing a module script failed/i,/Failed to fetch dynamically imported module/i,/error loading dynamically imported module/i,/Loading chunk \S+ failed/i,/ChunkLoadError/i];function g(e){if(e instanceof Error)return`${e.name}\n${e.message}\n${e.stack??``}`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}}function _(e){let t=g(e);return h.some(e=>e.test(t))}function v(e){return`1.19.11-beta.2:${g(e).slice(0,500)}`}function y(e){if(typeof window>`u`)return;let t=Date.now(),n=v(e),r=null;try{let e=window.sessionStorage.getItem(f);if(e)try{r=JSON.parse(e)}catch{r=null}}catch{return}let i=typeof r?.timestamp==`number`?r.timestamp:0;if(!(r?.signature===n&&t-i<m)){try{window.sessionStorage.setItem(f,JSON.stringify({signature:n,timestamp:t}))}catch{return}console.error(`[refresh-debug] chunk-import-reload`,{error:g(e).slice(0,1e3)}),window.setTimeout(()=>{window.location.reload()},0)}}async function b(e,t={}){let n=t.retries??1,r;for(let t=0;t<=n;t+=1)try{return await e()}catch(e){if(r=e,!_(e)||t>=n)break;await new Promise(e=>setTimeout(e,p*(t+1)))}throw _(r)&&y(r),r}var x=e=>(0,d.lazy)(()=>b(e)),S=(e,t)=>{try{let n=t??(typeof localStorage>`u`?null:localStorage);return n?n.getItem(e)===`1`:!1}catch{return!1}},C=(e,t)=>{try{let n=t??(typeof localStorage>`u`?null:localStorage);return!n||n.getItem(e)!==`0`}catch{return!0}},w=(e,t)=>{try{typeof localStorage<`u`&&localStorage.setItem(e,t?`1`:`0`)}catch{}},T=`oc:legend-timeline`,E=e=>S(T,e),D=`oc:markstream-react`,O=e=>C(D,e),k=t(e=>({legendTimelineEnabled:E(),setLegendTimelineEnabled:t=>{w(T,t),e({legendTimelineEnabled:t})},markstreamReactEnabled:O(),setMarkstreamReactEnabled:t=>{w(D,t),e({markstreamReactEnabled:t})}})),A=null,j=()=>(A??=i(()=>import(`./MarkstreamRendererImpl-Cn8_l6dk.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])).catch(e=>{throw A=null,e}),A),M=()=>{j().catch(()=>void 0)},N=null,P=()=>(N??=i(()=>import(`./MarkdownRendererImpl-NkxEMuNz.js`).then(e=>e.n),__vite__mapDeps([34,1,13,14,2,15,16,3,17,18,19,5,35,9,10,11,22,23,12,20,21,24,25,26,27,28,29,36,37,38,39,40,41,42,43,44,45,46,30,31,47,33,32,48,49,50,51,52,53])).catch(e=>{throw N=null,e}),N),F=()=>{P().catch(()=>void 0),O()&&M()},I=class extends d.Component{state={hasError:!1};static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(e){console.warn(`[markstream-react] falling back to the current Markdown renderer`,e)}render(){return this.state.hasError?this.props.fallback:this.props.children}},L=d.createContext(!0),R=()=>d.useContext(L),z=n(),B=x(()=>P().then(e=>({default:e.MarkdownRenderer}))),V=x(()=>j().then(e=>({default:e.MarkstreamRenderer}))),H=x(()=>P().then(e=>({default:e.SimpleMarkdownRenderer}))),U=e=>e===`tool`?`markdown-content markdown-tool`:e===`reasoning`?`markdown-content markdown-reasoning`:`markdown-content leading-relaxed`,W=e=>{let t=(0,u.c)(15),n=k(q),r=typeof e.content==`string`?e.content:``,i=typeof e.variant==`string`?e.variant:`assistant`,a;t[0]!==r||t[1]!==n||t[2]!==e.reserveMeasuredHeight||t[3]!==i?(a=e.reserveMeasuredHeight?c(l(r,n?`markstream:${i}`:i)):void 0,t[0]=r,t[1]=n,t[2]=e.reserveMeasuredHeight,t[3]=i,t[4]=a):a=t[4];let d=a,f;t[5]!==e.className||t[6]!==e.variant?(f=o(`relative break-words w-full min-w-0`,U(e.variant),typeof e.className==`string`?e.className:void 0),t[5]=e.className,t[6]=e.variant,t[7]=f):f=t[7];let p;t[8]!==r||t[9]!==e.animated||t[10]!==d?(p=(0,z.jsx)(s,{animated:e.animated,content:r,reservedHeight:d}),t[8]=r,t[9]=e.animated,t[10]=d,t[11]=p):p=t[11];let m;return t[12]!==f||t[13]!==p?(m=(0,z.jsx)(`div`,{className:f,"aria-busy":`true`,"data-markdown-hydration":`deferred`,"data-markdown-ready":`false`,children:p}),t[12]=f,t[13]=p,t[14]=m):m=t[14],m},G=e=>{let t=(0,u.c)(9),n=R(),r=k(J);if(!n&&e.isStreaming!==!0){let n;return t[0]===e?n=t[1]:(n=(0,z.jsx)(W,{...e,animated:!1,reserveMeasuredHeight:!0}),t[0]=e,t[1]=n),n}let i;t[2]===e?i=t[3]:(i=(0,z.jsx)(d.Suspense,{fallback:(0,z.jsx)(W,{...e,reserveMeasuredHeight:!0}),children:(0,z.jsx)(B,{...e})}),t[2]=e,t[3]=i);let a=i;if(!r)return a;let o;t[4]===e?o=t[5]:(o=(0,z.jsx)(d.Suspense,{fallback:(0,z.jsx)(W,{...e,reserveMeasuredHeight:!0}),children:(0,z.jsx)(V,{...e})}),t[4]=e,t[5]=o);let s;return t[6]!==a||t[7]!==o?(s=(0,z.jsx)(I,{fallback:a,children:o}),t[6]=a,t[7]=o,t[8]=s):s=t[8],s},K=e=>{let t=(0,u.c)(4);if(!R()){let n;return t[0]===e?n=t[1]:(n=(0,z.jsx)(W,{...e,animated:!1}),t[0]=e,t[1]=n),n}let n;return t[2]===e?n=t[3]:(n=(0,z.jsx)(d.Suspense,{fallback:(0,z.jsx)(W,{...e}),children:(0,z.jsx)(H,{...e})}),t[2]=e,t[3]=n),n};function q(e){return e.markstreamReactEnabled}function J(e){return e.markstreamReactEnabled}export{k as a,F as i,K as n,x as o,L as r,G as t};