@openchambery/web 1.19.11 → 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 (50) hide show
  1. package/dist/assets/{AssistantView-BwrcqJzu.js → AssistantView-DxIghtun.js} +1 -1
  2. package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BeUgp_Y0.js} +1 -1
  3. package/dist/assets/{MarkdownRenderer-C49bfcHx.js → MarkdownRenderer-C4MGa_kQ.js} +2 -2
  4. package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-DB4Q3kGa.js} +1 -1
  5. package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-BFdJzxia.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-LZU2vFAs.js → MobileDetailNavigation-B_RwWOwv.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-UiOLx0-2.js} +1 -1
  8. package/dist/assets/{MobileSurface-OAJjAQCZ.js → MobileSurface-CNChj_dX.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-C3p3lj3U.js} +1 -1
  10. package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BXfKsz0J.js} +1 -1
  11. package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-8IDuPZA5.js} +1 -1
  12. package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-BiKCueth.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-BC2F_Fdl.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-B87OLIgc.js} +1 -1
  15. package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-tTtHQVA3.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CCQpZ5zn.js} +1 -1
  17. package/dist/assets/{main-BI7fNQoi.js → main-C_PUoT3E.js} +4 -4
  18. package/dist/assets/{main-D0L7fIg3.js → main-Dkg31fRY.js} +2 -2
  19. package/dist/assets/{miniChat-BJ9AclZX.js → miniChat-C9QMj8T8.js} +2 -2
  20. package/dist/assets/{mobile-OcCu_0mG.js → mobile-Z_X7CbzB.js} +2 -2
  21. package/dist/assets/{multirun-B0G_BEIN.js → multirun-B0V3hdnD.js} +1 -1
  22. package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-DRW9MQ4z.js} +1 -1
  23. package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-Dn1Su1dd.js} +2 -2
  24. package/dist/assets/{renderMobileApp-C4igZ42L.js → renderMobileApp-BG1JJt1Q.js} +4 -4
  25. package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-C6matIEB.js} +1 -1
  26. package/dist/assets/{runtimeEndpointReset-6lVRspis.js → runtimeEndpointReset-kgwmUJDb.js} +1 -1
  27. package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-B6P_aCun.js} +1 -1
  28. package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-Dmes1chr.js} +5 -5
  29. package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DuZ6URPR.js} +1 -1
  30. package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-CU2ChP0J.js} +1 -1
  31. package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-C8_J-qTp.js} +1 -1
  32. package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-FOQeu1_T.js} +1 -1
  33. package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.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
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-BJ9AclZX.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-C9QMj8T8.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -91,7 +91,7 @@
91
91
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-947l7Ra3.js">
92
92
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
93
93
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
94
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-P8nVMYzE.js">
94
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-C6matIEB.js">
95
95
  <link rel="stylesheet" crossorigin href="/assets/src-B-P5qIBc.css">
96
96
  </head>
97
97
  <body class="h-full bg-background text-foreground">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-OcCu_0mG.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-Z_X7CbzB.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
@@ -80,7 +80,7 @@
80
80
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-947l7Ra3.js">
81
81
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
82
82
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
83
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-P8nVMYzE.js">
83
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-C6matIEB.js">
84
84
  <link rel="stylesheet" crossorigin href="/assets/src-B-P5qIBc.css">
85
85
  </head>
86
86
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.11",
3
+ "version": "1.19.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Acceptance criteria
4
4
 
5
- The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools; file and shell work runs in-process through pi.
5
+ The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `promptAsync` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
6
6
 
7
7
  Contact turns retain all eight OpenChamber operation tools alongside the four
8
8
  pi tools. Skill discovery contributes instructions to this combined tool set.
@@ -22,7 +22,9 @@ and keep their existing error result handling.
22
22
 
23
23
  - `assign_session` is an OpenChamber AgentTool on the contact harness. It creates or reuses a visible OpenCode/OpenChamber session on a **registered project path** (Settings projects / `getAllowedRoots()`), optionally an **existing** Chat worktree/branch via `getWorktrees`, then kicks the coding prompt into that session with `session.create` + `session.promptAsync` (same worker path as `POST /api/openchamber/conversations`).
24
24
  - That worker session is the coder. The contact does not code. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
25
- - After a successful assign, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch`.
25
+ - Successful `assign_session` returns `terminate: true` so the pi agent loop does **not** auto-follow-up the LLM (prevents a looping model from re-assigning dozens of times in one turn). Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign.
26
+ - Same contact-turn assign gate (tools instance lifetime only, not cross-turn): identical args after success replay the cached success result (no second worker); different args after success, or parallel mismatched args, fail closed with a validation error. Failure clears the gate so a corrected retry may run. Parallel identical args share one in-flight promise.
27
+ - After a successful assign, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch`. The user-facing outcome is the session card plus a short confirm bubble.
26
28
  - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question`) and appends one idempotent settle bubble (`oc.settle.*`). Session-goal settle (`emitGoalNotification`) is wired into that same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成.
27
29
  - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and appends the existing `oc.settle.complete` bubble. `session.error` or last-assistant `info.error` settles 失败. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete.
28
30
  - Worker sessions stamp `metadata.openchamber.assigned` (`from: 'contact'`, `assistantID`, `name`). They must **not** use `openchamber.assistant.assistantID` — that marker hides archived Assistant bindings from Chat.
@@ -42,7 +44,7 @@ and keep their existing error result handling.
42
44
  - `create_assistant` calls the same in-process `createAssistant` (`name`, connected OpenCode `providerID`/`modelID`, `mode: 'continuous'`). It emits an `assistant` card that opens that contact.
43
45
  - `schedule_task` calls in-process `projectConfigRuntime.upsertScheduledTask` with the same payload as `PUT /api/projects/:id/scheduled-tasks` (`name`, `schedule.kind/time/timezone`, `execution.prompt` + provider/model) on a registered project, then best-effort `scheduledTasksRuntime.syncProject`. It emits a `schedule` card.
44
46
  - Successful `schedule_task` also writes an assistants-owned mapping row in `assistants.sqlite` (`assistant_scheduled_task`: assistantID + projectID + taskID). Project-config execution strips unknown fields, so ownership stays out of the task payload. `GET /api/openchamber/assistants/:id/scheduled-tasks` returns those mappings joined with live `listScheduledTasks` records when possible; failed project lookups keep the mapping with `task: null`.
45
- - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 建助理 / 建会话 / 排定时任务 / 给 X 说一声). A reply without the tool call does nothing; never say 已创建 / 已发送 unless the tool returned. Completions are messages-only (no native tools), so `parseContactToolCalls` accepts a fence, a whole-message JSON object, or a bare `{name, arguments}` object anywhere in the reply.
47
+ - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` + `formatPiCodingPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 建助理 / 建会话 / 排定时任务 / 给 X 说一声). Catalog lines include each tool's JSON argument schema (pi `write`/`edit` require `path`+`content` / `path`+`edits[{oldText,newText}]`). A reply without the tool call does nothing; never say 已创建 / 已发送 unless the tool returned. Completions are messages-only (no native tools), so `parseContactToolCalls` accepts a fence, a whole-message JSON object, or a bare `{name, arguments}` object anywhere in the reply. Tool results are re-injected as user turns labeled `OpenChamber tool result name=<tool> call=<id>` so multi-step turns keep call/result association.
46
48
  - If the user asked for `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session` and the first completion has no tool call, the harness runs **one** follow-up completion whose user content is only `emit the fence now, do not claim success.` If still no tool, the turn replies that it could not complete and does not fake a card. No slash commands. No second store.
47
49
 
48
50
  **Clear memory vs clear chat history (this PR)**
@@ -1,4 +1,4 @@
1
- import { AssignError, PROJECT_REQUIRED_MESSAGE } from './assign.js';
1
+ import { AssignError, ASSIGN_CODES, PROJECT_REQUIRED_MESSAGE } from './assign.js';
2
2
  import { createAssistantCardPart, createScheduleCardPart, createSessionCardPart } from './cards.js';
3
3
  import { isPiCodingToolName } from './pi-tools.js';
4
4
 
@@ -32,6 +32,8 @@ export const LIST_PROJECTS_TOOL_NAME = 'list_projects';
32
32
  export const LIST_SESSIONS_TOOL_NAME = 'list_sessions';
33
33
  const CONTACT_TOOL_FENCE = 'openchamber-tool';
34
34
  export const ASSIGNED_SESSION_FALLBACK_BUBBLE = 'Opened a coding session.';
35
+ /** Same-turn duplicate assign (different args after a success, or parallel mismatch). */
36
+ export const ASSIGN_DUPLICATE_TURN_MESSAGE = 'This contact turn already opened a coding session. Do not assign again.';
35
37
  /** Clear-memory confirm: transcript rows stay; only the LLM window resets. */
36
38
  export const NEW_CONVERSATION_CONFIRM_BUBBLE = 'Memory cleared. Previous messages stay in the chat; I will not use them as context.';
37
39
  /** Explicit transcript wipe confirm (clear_chat_history / POST contact/reset). */
@@ -39,6 +41,23 @@ export const CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE = 'Chat history cleared.';
39
41
  const LIST_SESSIONS_LIMIT_DEFAULT = 20;
40
42
  const LIST_SESSIONS_LIMIT_MAX = 50;
41
43
 
44
+ /** Stable key for same-turn assign dedup (not cross-turn). */
45
+ export function normalizeAssignRequestKey(params = {}) {
46
+ const pick = (value) => {
47
+ if (typeof value === 'string') return value.trim();
48
+ if (value == null) return '';
49
+ return String(value).trim();
50
+ };
51
+ return JSON.stringify({
52
+ prompt: pick(params?.prompt),
53
+ projectPath: pick(params?.projectPath),
54
+ directory: pick(params?.directory),
55
+ branch: pick(params?.branch),
56
+ sessionID: pick(params?.sessionID),
57
+ title: pick(params?.title),
58
+ });
59
+ }
60
+
42
61
  const DENIED_CODING_TOOLS = new Set(['glob', 'grep', 'shell', 'find', 'ls', 'powershell']);
43
62
 
44
63
  const FENCE = new RegExp(`\`\`\`${CONTACT_TOOL_FENCE}\\s*([\\s\\S]*?)\`\`\``, 'u');
@@ -414,6 +433,35 @@ export function resolvePeerAssistant(params = {}, assistants = [], currentAssist
414
433
  return matches[0];
415
434
  }
416
435
 
436
+ /**
437
+ * Serialize a tool parameter schema for the contact system prompt.
438
+ * Accepts plain JSON-schema-like objects and TypeBox schemas (JSON.stringify).
439
+ * Strips non-enumerable TypeBox markers (~kind / ~optional).
440
+ */
441
+ export function serializeToolParametersForPrompt(parameters) {
442
+ if (parameters == null) return '{"type":"object","properties":{}}';
443
+ try {
444
+ const text = JSON.stringify(parameters, (_key, value) => {
445
+ if (typeof value === 'function' || typeof value === 'symbol') return undefined;
446
+ return value;
447
+ });
448
+ if (typeof text === 'string' && text.trim() && text !== 'undefined') return text;
449
+ } catch {
450
+ // Fall through to empty object schema.
451
+ }
452
+ return '{"type":"object","properties":{}}';
453
+ }
454
+
455
+ /** One catalog line: name, description, and full arguments schema. */
456
+ export function formatApplicationToolCatalogEntry(tool) {
457
+ if (!tool || typeof tool.name !== 'string' || !tool.name.trim()) return '';
458
+ const name = tool.name.trim();
459
+ const description = typeof tool.description === 'string' && tool.description.trim()
460
+ ? tool.description.trim()
461
+ : (typeof tool.label === 'string' && tool.label.trim() ? tool.label.trim() : name);
462
+ return `- ${name}: ${description}\n arguments schema: ${serializeToolParametersForPrompt(tool.parameters)}`;
463
+ }
464
+
417
465
  export function formatContactToolsPrompt(tools) {
418
466
  const list = Array.isArray(tools)
419
467
  ? tools.filter((tool) => tool?.name && !DENIED_CODING_TOOLS.has(tool.name) && !isPiCodingToolName(tool.name))
@@ -455,11 +503,12 @@ export function formatContactToolsPrompt(tools) {
455
503
  `{"name":"${ASSIGN_SESSION_TOOL_NAME}","arguments":{"prompt":"...","projectPath":"..."}}`,
456
504
  '```',
457
505
  'If the user asked for more than one of these, do them in that order across turns: new_conversation or clear_chat_history, then list_projects, then list_sessions, then create_assistant, then schedule_task, then message_assistant, then assign_session.',
506
+ 'Prerequisite lookups (list_projects / list_sessions) may run before assign_session in the same turn. After a successful assign_session the turn ends — never call assign_session again, and do not keep looping tools.',
458
507
  'new_conversation advances this contact\'s LLM context boundary only. Stored messages and watches remain. It never calls session/new or createNew.',
459
508
  'clear_chat_history deletes this contact\'s stored messages, parts, and watches. Use only for explicit wipe intent.',
460
509
  'You already receive the registered project catalog each turn. Prefer matching label/path yourself; list_projects refreshes or filters. Never claim you cannot see projects; never ask for a raw path when a name matches.',
461
510
  'list_sessions searches the OpenChamber session index for existing chats in a project. A failure is not an empty list — surface the error.',
462
- 'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker.',
511
+ 'assign_session opens a real OpenChamber/OpenCode session on a registered project (or reuses sessionID). You are not the worker. One successful assign ends this turn.',
463
512
  'create_assistant reuses already-connected OpenCode providers (providerID/modelID). Mode is continuous.',
464
513
  'schedule_task writes the same payload as PUT /api/projects/:id/scheduled-tasks onto a registered project.',
465
514
  'message_assistant is read-only: it inserts into the other contact transcript. It never runs promptAsync or mutates sessions or files. Never assign through a peer message.',
@@ -467,9 +516,10 @@ export function formatContactToolsPrompt(tools) {
467
516
  'When calling a tool that takes a beat (找项目 / 开会话), you may say one short spoken line first (≤40 characters, e.g. 我去找一下), then only the fence. No planning, no tool names, no "let me think".',
468
517
  'Never say 已创建, 已发送, created, scheduled, opened, or sent unless the tool already returned success.',
469
518
  'If no registered project exists, tell the user to add one in Settings — do not use assistant-workspaces.',
470
- 'After a successful tool, confirm in one short bubble. The user sees a contact card, not tool traces.',
519
+ 'After a successful tool, confirm in one short bubble. The user sees a contact card, not tool traces. After successful assign_session the session card plus that short confirm is enough — stop.',
520
+ 'These are application-owned OpenChamber contact tools (not OpenCode native tools, not MCP, not skill directory entries). Use only the names and argument schemas below:',
471
521
  'Available OpenChamber tools:',
472
- ...list.map((tool) => `- ${tool.name}: ${tool.description || tool.label || tool.name}`),
522
+ ...list.map((tool) => formatApplicationToolCatalogEntry(tool)).filter(Boolean),
473
523
  ].join('\n');
474
524
  }
475
525
 
@@ -553,6 +603,64 @@ export function createContactTools({
553
603
  if (typeof onCard === 'function') onCard(card);
554
604
  };
555
605
 
606
+ /**
607
+ * Same contact-turn assign gate only (tools instance = one harness turn).
608
+ * - Same args after success → cached success (no second worker).
609
+ * - Different args after success / parallel mismatch → reject.
610
+ * - Failure clears the gate so a corrected retry may run.
611
+ * Not cross-turn.
612
+ */
613
+ let assignTurnGate = null;
614
+
615
+ const executeAssignOnce = async (params) => {
616
+ const key = normalizeAssignRequestKey(params);
617
+ if (assignTurnGate?.status === 'done') {
618
+ if (assignTurnGate.key === key) return assignTurnGate.result;
619
+ throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
620
+ }
621
+ if (assignTurnGate?.status === 'pending') {
622
+ if (assignTurnGate.key === key) return assignTurnGate.promise;
623
+ throw new AssignError(ASSIGN_CODES.VALIDATION, ASSIGN_DUPLICATE_TURN_MESSAGE);
624
+ }
625
+
626
+ let settle;
627
+ const promise = new Promise((resolve, reject) => {
628
+ settle = { resolve, reject };
629
+ });
630
+ assignTurnGate = { status: 'pending', key, promise };
631
+
632
+ void (async () => {
633
+ try {
634
+ if (typeof assignWork !== 'function') {
635
+ throw new AssignError('upstream_error', 'Assign is unavailable.');
636
+ }
637
+ const assigned = await assignWork(params || {});
638
+ const card = createSessionCardPart({
639
+ sessionID: assigned.sessionID,
640
+ directory: assigned.directory,
641
+ title: assigned.title,
642
+ status: assigned.status || 'busy',
643
+ branch: assigned.branch,
644
+ });
645
+ emitCard(card);
646
+ const result = {
647
+ // Short confirm for the transcript; session id lives on the card details.
648
+ content: [{ type: 'text', text: ASSIGNED_SESSION_FALLBACK_BUBBLE }],
649
+ details: { card, assigned },
650
+ // End the pi agent loop so a looping model cannot re-assign 37 times.
651
+ terminate: true,
652
+ };
653
+ assignTurnGate = { status: 'done', key, result };
654
+ settle.resolve(result);
655
+ } catch (error) {
656
+ assignTurnGate = null;
657
+ settle.reject(error);
658
+ }
659
+ })();
660
+
661
+ return promise;
662
+ };
663
+
556
664
  return [
557
665
  {
558
666
  name: NEW_CONVERSATION_TOOL_NAME,
@@ -816,28 +924,12 @@ export function createContactTools({
816
924
  description: [
817
925
  'Open or reuse a real OpenChamber coding session on a registered project path',
818
926
  'and kick the prompt into that session. Optional existing worktree branch or sessionID.',
819
- 'Never codes here. Never uses assistant-workspaces.',
927
+ 'Successful assign ends this contact turn (terminate). Never codes here. Never uses assistant-workspaces.',
820
928
  ].join(' '),
821
929
  parameters: assignParameters,
822
930
  execute: async (_toolCallId, params) => {
823
931
  try {
824
- if (typeof assignWork !== 'function') {
825
- throw new AssignError('upstream_error', 'Assign is unavailable.');
826
- }
827
- const assigned = await assignWork(params || {});
828
- const card = createSessionCardPart({
829
- sessionID: assigned.sessionID,
830
- directory: assigned.directory,
831
- title: assigned.title,
832
- status: assigned.status || 'busy',
833
- branch: assigned.branch,
834
- });
835
- emitCard(card);
836
- return {
837
- content: [{ type: 'text', text: `Opened coding session ${assigned.sessionID}. The user will see a session card.` }],
838
- details: { card, assigned },
839
- terminate: false,
840
- };
932
+ return await executeAssignOnce(params || {});
841
933
  } catch (error) {
842
934
  return toolFailure(error, 'assign_failed', 'Could not assign a coding session.');
843
935
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { AssignError, ASSIGN_CODES, PROJECT_REQUIRED_MESSAGE } from './assign.js';
3
3
  import {
4
+ ASSIGN_DUPLICATE_TURN_MESSAGE,
4
5
  ASSIGN_SESSION_TOOL_NAME,
5
6
  CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE,
6
7
  CLEAR_CHAT_HISTORY_TOOL_NAME,
@@ -18,6 +19,7 @@ import {
18
19
  formatContactToolsPrompt,
19
20
  formatRegisteredProjectsPrompt,
20
21
  matchesProjectQuery,
22
+ normalizeAssignRequestKey,
21
23
  parseContactToolCalls,
22
24
  resolveContactProviderModel,
23
25
  resolvePeerAssistant,
@@ -160,6 +162,13 @@ describe('contact tool protocol', () => {
160
162
  expect(prompt).toContain('A reply without the tool call does nothing');
161
163
  expect(prompt).toContain('已创建');
162
164
  expect(prompt).toContain('Never claim you cannot see projects');
165
+ expect(prompt).toContain('One successful assign ends this turn');
166
+ expect(prompt).toContain('application-owned OpenChamber contact tools');
167
+ expect(prompt).toContain('arguments schema:');
168
+ expect(prompt).toContain('"prompt"');
169
+ expect(prompt).toContain('"projectPath"');
170
+ expect(prompt).toContain('"to"');
171
+ expect(prompt).toContain('"text"');
163
172
  });
164
173
 
165
174
  it('fuzzy-matches project labels like openchamber yee / openchamer yee', () => {
@@ -292,9 +301,56 @@ describe('createContactTools', () => {
292
301
  sessionID: 'ses_1',
293
302
  title: 'Login',
294
303
  });
304
+ expect(assigned.terminate).toBe(true);
295
305
  expect(onCard).toHaveBeenCalledTimes(3);
296
306
  });
297
307
 
308
+ it('same-turn assign gate caches identical success and rejects a different second assign', async () => {
309
+ const assignWork = vi.fn(async () => ({
310
+ sessionID: 'ses_once',
311
+ directory: '/repo',
312
+ title: 'Once',
313
+ status: 'busy',
314
+ }));
315
+ const tools = createContactTools({ assignWork });
316
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
317
+ const args = { prompt: 'Fix login', projectPath: '/repo' };
318
+ const first = await assign.execute('call_a', args);
319
+ const secondSame = await assign.execute('call_b', { ...args });
320
+ expect(assignWork).toHaveBeenCalledTimes(1);
321
+ expect(secondSame.details.assigned.sessionID).toBe('ses_once');
322
+ expect(secondSame.terminate).toBe(true);
323
+ expect(normalizeAssignRequestKey(args)).toBe(normalizeAssignRequestKey({ ...args, title: ' ' }));
324
+ const different = await assign.execute('call_c', { prompt: 'Other work', projectPath: '/repo' });
325
+ expect(different.details.error).toBe('validation_error');
326
+ expect(different.content[0].text).toContain(ASSIGN_DUPLICATE_TURN_MESSAGE);
327
+ expect(different.terminate).toBe(true);
328
+ expect(assignWork).toHaveBeenCalledTimes(1);
329
+ });
330
+
331
+ it('same-turn parallel identical assigns share one worker create', async () => {
332
+ let release;
333
+ const barrier = new Promise((resolve) => { release = resolve; });
334
+ const assignWork = vi.fn(async () => {
335
+ await barrier;
336
+ return { sessionID: 'ses_parallel', directory: '/repo', title: 'P', status: 'busy' };
337
+ });
338
+ const tools = createContactTools({ assignWork });
339
+ const assign = tools.find((tool) => tool.name === ASSIGN_SESSION_TOOL_NAME);
340
+ const args = { prompt: 'Fix', projectPath: '/repo' };
341
+ const pending = Promise.all([
342
+ assign.execute('call_1', args),
343
+ assign.execute('call_2', args),
344
+ ]);
345
+ release();
346
+ const [a, b] = await pending;
347
+ expect(assignWork).toHaveBeenCalledTimes(1);
348
+ expect(a.details.assigned.sessionID).toBe('ses_parallel');
349
+ expect(b.details.assigned.sessionID).toBe('ses_parallel');
350
+ expect(a.terminate).toBe(true);
351
+ expect(b.terminate).toBe(true);
352
+ });
353
+
298
354
  it('resolves a peer by name and rejects a missing recipient', () => {
299
355
  const listed = [{ id: 'asst_peer', name: 'PeerQA' }, { id: 'asst_host', name: 'DeepSeekQA' }];
300
356
  expect(resolvePeerAssistant({ to: 'PeerQA' }, listed, { id: 'asst_host' })).toEqual(listed[0]);
@@ -79,7 +79,7 @@ export const CONTACT_SYSTEM_PROMPT = [
79
79
  'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
80
80
  'Understand natural language in any language, including Chinese: 开新对话 / 清除记忆 means new_conversation (LLM memory only, chat history stays), 清空聊天记录 means clear_chat_history (delete transcript), 找项目 means list_projects, 现有对话 means list_sessions, 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
81
81
  'You receive the registered project catalog every turn. You CAN see those projects. Look them up yourself (fuzzy match label/name/path). Never say you cannot see the registered project list. Never ask for a raw filesystem path when a name matches. If the catalog is empty, tell the user to add a project in Settings.',
82
- 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID.',
82
+ 'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID. One successful assign_session ends the turn — do not call it again.',
83
83
  'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
84
84
  ].join(' ');
85
85
 
@@ -259,7 +259,17 @@ export function createContactStreamFn(createChatCompletion, {
259
259
  .filter((part) => part?.type === 'text')
260
260
  .map((part) => part.text)
261
261
  .join('');
262
- return content ? [{ role: 'user', content: `OpenChamber tool ${message.toolName || 'result'}: ${content}` }] : [];
262
+ if (!content) return [];
263
+ const toolName = typeof message.toolName === 'string' && message.toolName.trim()
264
+ ? message.toolName.trim()
265
+ : 'result';
266
+ const callId = typeof message.toolCallId === 'string' && message.toolCallId.trim()
267
+ ? message.toolCallId.trim()
268
+ : (typeof message.id === 'string' && message.id.trim() ? message.id.trim() : '');
269
+ const label = callId
270
+ ? `OpenChamber tool result name=${toolName} call=${callId}`
271
+ : `OpenChamber tool result name=${toolName}`;
272
+ return [{ role: 'user', content: `${label}: ${content}` }];
263
273
  }
264
274
  return [];
265
275
  });
@@ -473,7 +483,11 @@ export async function runContactTurn({
473
483
  const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
474
484
  const systemPrompt = [
475
485
  CONTACT_SYSTEM_PROMPT,
476
- formatPiCodingPrompt({ cwd: runtime?.cwd, skillsPrompt: runtime?.skillsPrompt }),
486
+ formatPiCodingPrompt({
487
+ cwd: runtime?.cwd,
488
+ skillsPrompt: runtime?.skillsPrompt,
489
+ tools: codingTools,
490
+ }),
477
491
  formatRegisteredProjectsPrompt(projects),
478
492
  formatContactToolsPrompt(contactTools),
479
493
  assistant.defaultPrompt,
@@ -229,6 +229,10 @@ describe('runContactTurn', () => {
229
229
  const createChatCompletion = vi.fn(async ({ body }) => {
230
230
  expect(body.messages[0].content).toContain('assign_session')
231
231
  expect(body.messages[0].content).toContain('docs')
232
+ expect(body.messages[0].content).toContain('arguments schema:')
233
+ expect(body.messages[0].content).toContain('"edits"')
234
+ expect(body.messages[0].content).toContain('"content"')
235
+ expect(body.messages[0].content).toContain('not OpenCode native tools and not MCP')
232
236
  const content = replies[call++]
233
237
  if (!content) throw new Error('Unexpected completion')
234
238
  return { completion: { choices: [{ message: { content } }] } }
@@ -242,11 +246,126 @@ describe('runContactTurn', () => {
242
246
  expect(result.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES, ...tools.map((tool) => tool.name)])
243
247
  expect(assignWork).toHaveBeenCalledTimes(1)
244
248
  expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_mixed' })])
245
- expect(createChatCompletion).toHaveBeenCalledTimes(4)
249
+ // read → text (miss) → missed-fence retry assign; assign terminates (no post-assign LLM).
250
+ expect(createChatCompletion).toHaveBeenCalledTimes(3)
251
+ // Tool call/result association: after read, the next completion sees a named tool result.
252
+ const secondMessages = createChatCompletion.mock.calls[1][0].body.messages
253
+ const toolResultTurn = secondMessages.find((message) => (
254
+ message.role === 'user' && typeof message.content === 'string' && message.content.includes('tool result name=read')
255
+ ))
256
+ expect(toolResultTurn).toBeTruthy()
257
+ expect(toolResultTurn.content).toMatch(/Read project docs|documentation/i)
246
258
  } finally {
247
259
  fs.rmSync(workspace, { recursive: true, force: true })
248
260
  }
249
261
  })
262
+ it('real Agent loop: looping assign fences create only one worker + one card (terminate)', async () => {
263
+ const assignWork = vi.fn(async () => ({
264
+ sessionID: 'ses_once',
265
+ directory: '/repo',
266
+ title: 'Once',
267
+ status: 'busy',
268
+ }))
269
+ const tools = createContactTools({ assignWork })
270
+ let completions = 0
271
+ const createChatCompletion = vi.fn(async () => {
272
+ completions += 1
273
+ // Safety: a non-terminating loop must not mint 37 sessions in tests.
274
+ if (completions > 8) {
275
+ throw new Error('harness safety stop: too many completions without terminate')
276
+ }
277
+ return {
278
+ completion: {
279
+ choices: [{
280
+ message: {
281
+ content: '```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix login","projectPath":"/repo"}}\n```',
282
+ },
283
+ }],
284
+ },
285
+ }
286
+ })
287
+ const result = await runContactTurn({
288
+ assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
289
+ history: [],
290
+ userText: '建会话修 login',
291
+ tools,
292
+ projects: [{ id: 'p1', path: '/repo', label: 'Repo' }],
293
+ createChatCompletion,
294
+ })
295
+ expect(assignWork).toHaveBeenCalledTimes(1)
296
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_once', cardType: 'session' })])
297
+ expect(result.cards).toHaveLength(1)
298
+ expect(result.bubbles).toContain('Opened a coding session.')
299
+ // First completion issues assign; terminate skips the auto follow-up LLM.
300
+ expect(createChatCompletion).toHaveBeenCalledTimes(1)
301
+ expect(completions).toBe(1)
302
+ })
303
+
304
+ it('real Agent loop: list_projects then assign keeps prereq + one session (terminate after assign)', async () => {
305
+ const listProjects = vi.fn(async () => [{ id: 'p1', path: '/repo', label: 'Repo' }])
306
+ const assignWork = vi.fn(async () => ({
307
+ sessionID: 'ses_prereq',
308
+ directory: '/repo',
309
+ title: 'Prereq',
310
+ status: 'busy',
311
+ }))
312
+ const tools = createContactTools({ listProjects, assignWork })
313
+ let completions = 0
314
+ const createChatCompletion = vi.fn(async ({ body }) => {
315
+ completions += 1
316
+ if (completions > 6) {
317
+ throw new Error('harness safety stop: too many completions')
318
+ }
319
+ const last = body.messages.at(-1)
320
+ const lastText = typeof last?.content === 'string' ? last.content : ''
321
+ if (lastText.includes('tool result name=list_projects') || lastText.includes('Registered projects')) {
322
+ return {
323
+ completion: {
324
+ choices: [{
325
+ message: {
326
+ content: '```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix","projectPath":"/repo"}}\n```',
327
+ },
328
+ }],
329
+ },
330
+ }
331
+ }
332
+ if (completions === 1) {
333
+ return {
334
+ completion: {
335
+ choices: [{
336
+ message: {
337
+ content: '```openchamber-tool\n{"name":"list_projects","arguments":{"query":"repo"}}\n```',
338
+ },
339
+ }],
340
+ },
341
+ }
342
+ }
343
+ // If assign did not terminate, a looping model would keep assigning — fail closed.
344
+ return {
345
+ completion: {
346
+ choices: [{
347
+ message: {
348
+ content: '```openchamber-tool\n{"name":"assign_session","arguments":{"prompt":"Fix again","projectPath":"/repo"}}\n```',
349
+ },
350
+ }],
351
+ },
352
+ }
353
+ })
354
+ const result = await runContactTurn({
355
+ assistant: { providerID: 'p', modelID: 'm', defaultPrompt: '' },
356
+ history: [],
357
+ userText: '找项目 repo 然后建会话修问题',
358
+ tools,
359
+ projects: [{ id: 'p1', path: '/repo', label: 'Repo' }],
360
+ createChatCompletion,
361
+ })
362
+ expect(listProjects).toHaveBeenCalledTimes(1)
363
+ expect(assignWork).toHaveBeenCalledTimes(1)
364
+ expect(result.cards).toEqual([expect.objectContaining({ sessionID: 'ses_prereq' })])
365
+ expect(result.cards).toHaveLength(1)
366
+ expect(createChatCompletion.mock.calls.length).toBeLessThanOrEqual(3)
367
+ })
368
+
250
369
  it('retries assignment after an unrelated read result', async () => {
251
370
  const prompts = []
252
371
  function AgentImpl(options) {
@@ -70,21 +70,68 @@ const bindHarnessTool = (tool, context) => ({
70
70
  ),
71
71
  });
72
72
 
73
- export function formatPiCodingPrompt({ cwd, skillsPrompt = '' } = {}) {
73
+ /**
74
+ * Serialize a tool parameter schema for the contact system prompt.
75
+ * Mirrors contact-tools.serializeToolParametersForPrompt without a circular import.
76
+ */
77
+ const serializeParameters = (parameters) => {
78
+ if (parameters == null) return '{"type":"object","properties":{}}';
79
+ try {
80
+ const text = JSON.stringify(parameters, (_key, value) => {
81
+ if (typeof value === 'function' || typeof value === 'symbol') return undefined;
82
+ return value;
83
+ });
84
+ if (typeof text === 'string' && text.trim() && text !== 'undefined') return text;
85
+ } catch {
86
+ // Fall through.
87
+ }
88
+ return '{"type":"object","properties":{}}';
89
+ };
90
+
91
+ const formatCodingToolEntry = (tool) => {
92
+ if (!tool || typeof tool.name !== 'string' || !tool.name.trim()) return '';
93
+ const name = tool.name.trim();
94
+ const description = typeof tool.description === 'string' && tool.description.trim()
95
+ ? tool.description.trim()
96
+ : name;
97
+ return `- ${name}: ${description}\n arguments schema: ${serializeParameters(tool.parameters)}`;
98
+ };
99
+
100
+ export function formatPiCodingPrompt({ cwd, skillsPrompt = '', tools = [] } = {}) {
74
101
  if (typeof cwd !== 'string' || !cwd.trim()) return '';
102
+ const codingTools = Array.isArray(tools)
103
+ ? tools.filter((tool) => isPiCodingToolName(tool?.name))
104
+ : [];
105
+ const catalog = codingTools.length > 0
106
+ ? codingTools.map(formatCodingToolEntry).filter(Boolean)
107
+ : [
108
+ '- bash: Execute a bash command in the working directory.\n arguments schema: {"type":"object","required":["command"],"properties":{"command":{"type":"string"},"timeout":{"type":"number"}}}',
109
+ '- read: Read a file (path required; optional offset/limit).\n arguments schema: {"type":"object","required":["path"],"properties":{"path":{"type":"string"},"offset":{"type":"number"},"limit":{"type":"number"}}}',
110
+ '- write: Write/create a file (path + content required).\n arguments schema: {"type":"object","required":["path","content"],"properties":{"path":{"type":"string"},"content":{"type":"string"}}}',
111
+ '- edit: Exact text replacement (path + edits[{oldText,newText}] required).\n arguments schema: {"type":"object","required":["path","edits"],"properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{"oldText":{"type":"string"},"newText":{"type":"string"}}}}}}',
112
+ ];
75
113
  return [
76
114
  `Working directory: ${cwd}`,
77
- 'You have pi coding tools in this directory: read, write, edit, bash.',
115
+ 'You have application-owned pi coding tools in this directory: read, write, edit, bash.',
116
+ 'These are not OpenCode native tools and not MCP. Skill directory entries below are instructions only — load a matching skill by calling read on its path; never invent skill or MCP tool names (no context7, gh_grep, webfetch, etc.).',
78
117
  'This is your working directory. Ignore any other cwd from the environment, including temporary generator workspaces under /var/folders or os.tmpdir.',
79
118
  'When the user asks pwd, the current directory, or to look at files, call bash/read yourself. Never say you have no terminal or cannot read or write files.',
80
119
  'Skills are merged from ~/.claude/skills, ~/.agents/skills, and this project\'s .claude/skills plus .agents/skills (project wins on name). Read a skill file when the task matches its description.',
81
- 'Call these tools with the same fenced JSON as other tools:',
120
+ 'Call these tools with the same fenced JSON as other tools. Match the argument schemas exactly:',
82
121
  '```openchamber-tool',
83
122
  '{"name":"bash","arguments":{"command":"pwd"}}',
84
123
  '```',
85
124
  '```openchamber-tool',
86
125
  '{"name":"read","arguments":{"path":".agents/skills/example/SKILL.md"}}',
87
126
  '```',
127
+ '```openchamber-tool',
128
+ '{"name":"write","arguments":{"path":"notes.txt","content":"hello"}}',
129
+ '```',
130
+ '```openchamber-tool',
131
+ '{"name":"edit","arguments":{"path":"notes.txt","edits":[{"oldText":"hello","newText":"hello world"}]}}',
132
+ '```',
133
+ 'Available pi coding tools (full argument schemas):',
134
+ ...catalog,
88
135
  typeof skillsPrompt === 'string' && skillsPrompt.trim() ? skillsPrompt.trim() : '',
89
136
  ].filter(Boolean).join('\n');
90
137
  }