fuli-context 0.9.0 → 0.11.0

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 (210) hide show
  1. package/README.md +29 -58
  2. package/README.zh-CN.md +24 -54
  3. package/dist/web/assets/AboutPage-CKq4IRHd.js +1 -0
  4. package/dist/web/assets/AgentConversations-DfXiFvBN.js +2 -0
  5. package/dist/web/assets/AgentConversations-XqNEBY_2.css +1 -0
  6. package/dist/web/assets/AgentHand-BBM-sKh2.js +1 -0
  7. package/dist/web/assets/AgentName-C_6R80nP.js +1 -0
  8. package/dist/web/assets/AgentProfilePage-Cz-i1fRD.js +1 -0
  9. package/dist/web/assets/AgentsDirectoryPage-BI0EFz6y.js +1 -0
  10. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +1 -0
  11. package/dist/web/assets/ConnectionsPage-BoC6kXgs.js +1 -0
  12. package/dist/web/assets/ConnectionsPage-CM9C-zEI.css +1 -0
  13. package/dist/web/assets/EmployeeRecruitDialog-BVyclQAJ.js +1 -0
  14. package/dist/web/assets/EmployeeRecruitDialog-CVbqCzHb.css +1 -0
  15. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +1 -0
  16. package/dist/web/assets/EmployeeWorkbenchPage-D2GvYraI.js +3 -0
  17. package/dist/web/assets/KnowledgeEditDialog-a0AKWics.js +1 -0
  18. package/dist/web/assets/KnowledgeEditDialog-hT9znkoe.css +1 -0
  19. package/dist/web/assets/KnowledgeInspector-DMg9yJZ2.js +1 -0
  20. package/dist/web/assets/KnowledgeOrganizerPage-BgsHM3Qw.js +1 -0
  21. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +1 -0
  22. package/dist/web/assets/KnowledgePage-kn8MqUCO.js +1 -0
  23. package/dist/web/assets/KnowledgeWorkspace-BtRq9fvC.js +4 -0
  24. package/dist/web/assets/KnowledgeWorkspace-D4a7FcAb.css +1 -0
  25. package/dist/web/assets/NotFoundPage-D-7jLz3o.js +1 -0
  26. package/dist/web/assets/OverviewPage-BHPc-XxY.js +1 -0
  27. package/dist/web/assets/OverviewPage-By-fUzSG.css +1 -0
  28. package/dist/web/assets/PersonalProfilePage-C00iV0Kx.js +1 -0
  29. package/dist/web/assets/PersonalProfilePage-DrMDAAdy.css +1 -0
  30. package/dist/web/assets/PersonalProjectsPage-CFNkdHSo.js +1 -0
  31. package/dist/web/assets/ProjectAgentFirstTask-Bi1ZsTC1.css +1 -0
  32. package/dist/web/assets/ProjectAgentFirstTask-CrCWEYgn.js +1 -0
  33. package/dist/web/assets/ProjectAgentsPage-BzztLMH-.js +12 -0
  34. package/dist/web/assets/ProjectAgentsPage-DNLNjYp-.css +1 -0
  35. package/dist/web/assets/ProjectScopePicker-CMxn3txv.js +1 -0
  36. package/dist/web/assets/PublicProjectsPage-DKamXDV9.css +1 -0
  37. package/dist/web/assets/PublicProjectsPage-DQVtvhOS.js +1 -0
  38. package/dist/web/assets/ReviewPage-BZhvyDWc.js +1 -0
  39. package/dist/web/assets/RoundtablesPage-CVebTXQa.js +1 -0
  40. package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +1 -0
  41. package/dist/web/assets/SettingsPage-1knvfOW6.js +1 -0
  42. package/dist/web/assets/SettingsPage-BmYkLul4.css +1 -0
  43. package/dist/web/assets/SharedJefaReportPage-BCVq8u0P.js +1 -0
  44. package/dist/web/assets/UiDialog-Da1P10f4.js +1 -0
  45. package/dist/web/assets/UiDisclosure-8wE8YuE4.js +1 -0
  46. package/dist/web/assets/UiSegmented-BKZXc8QF.js +1 -0
  47. package/dist/web/assets/UiSelect-Bv6R0yr1.css +1 -0
  48. package/dist/web/assets/UiSelect-D5Xj5Ec4.js +1 -0
  49. package/dist/web/assets/WritingTastePage-B8-etOBn.js +1 -0
  50. package/dist/web/assets/WritingTastePage-B_zYD1df.css +1 -0
  51. package/dist/web/assets/agent-profile-BZj3ezWe.css +1 -0
  52. package/dist/web/assets/{attention-store-Dk-_P4Oi.js → attention-store-BfxtL9EV.js} +1 -1
  53. package/dist/web/assets/{catalog-DN72452f.js → catalog-6SN9i3WR.js} +1 -1
  54. package/dist/web/assets/client-CUTkF3t4.js +1 -0
  55. package/dist/web/assets/{console-BtPqo79j.js → console-CG8tdtSs.js} +1 -1
  56. package/dist/web/assets/{conversation-launcher-settings-jkHRmWai.js → conversation-launcher-settings-CyZkLJg7.js} +1 -1
  57. package/dist/web/assets/{i18n-D2CJkEYK.js → i18n-CCMR4gDS.js} +3 -3
  58. package/dist/web/assets/{identity-B3zEw-1u.js → identity-C1eN-FSB.js} +1 -1
  59. package/dist/web/assets/index-kPB0Cf8Q.css +1 -0
  60. package/dist/web/assets/index-sLDKI6R-.js +5 -0
  61. package/dist/web/assets/{model-S7ehAjky.js → model-l-sAqtW_.js} +1 -1
  62. package/dist/web/assets/{personal-profile-graph--GG3Mizo.js → personal-profile-graph-BD1Y6TDS.js} +1 -1
  63. package/dist/web/assets/shared-report-B46dxlCR.js +1 -0
  64. package/dist/web/assets/{useAgentRoster-BAPKWZ9y.js → useAgentRoster-DdxoKDEE.js} +1 -1
  65. package/dist/web/assets/{useApi-CROJJdhE-BtFMXFjw.js → useApi-CROJJdhE-DlOiu8lP.js} +1 -1
  66. package/dist/web/assets/{vue-router-DkaTtPn0.js → vue-router-DjqEU4W2.js} +1 -1
  67. package/dist/web/index.html +14 -16
  68. package/docs/agent-conversations-and-collaboration.md +9 -9
  69. package/docs/agent-roundtable.md +61 -0
  70. package/docs/employee-agents.md +2 -2
  71. package/docs/project-agent-memory.md +1 -1
  72. package/graph-provider/fuli_graph/agent_conversation_models.py +11 -2
  73. package/graph-provider/fuli_graph/conversation_context.py +40 -9
  74. package/graph-provider/fuli_graph/default_project_lead.py +88 -0
  75. package/graph-provider/fuli_graph/project_agent_memory_routes.py +7 -0
  76. package/graph-provider/fuli_graph/project_agent_task_models.py +3 -0
  77. package/graph-provider/fuli_graph/runtime.py +3 -76
  78. package/graph-provider/fuli_graph/store.py +2 -0
  79. package/graph-provider/fuli_graph/store_agent_conversations.py +60 -8
  80. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +3 -1
  81. package/graph-provider/fuli_graph/store_project_agent_tasks.py +12 -8
  82. package/graph-provider/fuli_graph/store_project_agents.py +25 -124
  83. package/graph-provider/fuli_graph/store_task_contexts.py +26 -0
  84. package/graph-provider/fuli_graph/system_agents.py +128 -0
  85. package/graph-provider/fuli_graph/system_hr_identity.py +1 -1
  86. package/graph-provider/fuli_graph/task_context_routes.py +10 -0
  87. package/graph-provider/pyproject.toml +1 -1
  88. package/npm-shrinkwrap.json +2 -2
  89. package/package.json +2 -4
  90. package/skills/capturing-session-knowledge/SKILL.md +1 -1
  91. package/src/agent-roundtable/http.js +14 -0
  92. package/src/agent-roundtable/service.js +218 -0
  93. package/src/agent-roundtable/store.js +86 -0
  94. package/src/agent-roundtable/tools.js +61 -0
  95. package/src/agent-roundtable/wake.js +99 -0
  96. package/src/agent-tools/agent-conversation-definitions.js +3 -3
  97. package/src/agent-tools/graph-definitions.js +3 -2
  98. package/src/agent-tools/interface-catalog.js +0 -5
  99. package/src/agent-tools.js +2 -2
  100. package/src/agents/claude-code/lifecycle-hook.js +6 -19
  101. package/src/agents/claude-code/wake.js +37 -0
  102. package/src/agents/codex/lifecycle-hook.js +51 -27
  103. package/src/agents/codex/token-usage.js +235 -0
  104. package/src/agents/codex/wake.js +47 -0
  105. package/src/agents/cursor/lifecycle-hook.js +3 -9
  106. package/src/agents/identity-receipt.js +16 -6
  107. package/src/agents/session-log.js +34 -0
  108. package/src/agents/token-usage-resolver.js +147 -0
  109. package/src/agents/wake-registry.js +14 -0
  110. package/src/cli/command-registry.js +0 -2
  111. package/src/cli/setup-command.js +2 -5
  112. package/src/cli/update-command.js +13 -2
  113. package/src/cli.js +0 -5
  114. package/src/employees/management-policy.js +4 -2
  115. package/src/employees/manifest.js +3 -0
  116. package/src/employees/recruitment.js +3 -2
  117. package/src/graphiti/agent-conversations.js +11 -9
  118. package/src/graphiti/agent-knowledge-workflows.js +34 -7
  119. package/src/graphiti/federated-application.js +18 -6
  120. package/src/graphiti/project-agent-mapping.js +1 -1
  121. package/src/graphiti/project-agent-task-entry.js +19 -7
  122. package/src/graphiti/project-agent-workflows.js +6 -1
  123. package/src/graphiti/project-path-context.js +7 -2
  124. package/src/graphiti/provider-client.js +8 -0
  125. package/src/graphiti/repository-project-registration.js +17 -0
  126. package/src/graphiti/task-entry-preferences.js +5 -0
  127. package/src/http/api-router.js +5 -3
  128. package/src/http/error-mapping.js +0 -2
  129. package/src/mcp/instructions.js +5 -5
  130. package/src/mcp/session-id.js +1 -1
  131. package/src/mcp/tool-annotations.js +3 -2
  132. package/src/setup/agents.js +1 -41
  133. package/src/setup/graph-runtime.js +15 -2
  134. package/src/setup/session-skill.js +22 -2
  135. package/dist/web/assets/AboutPage-D-GyJNv1.js +0 -1
  136. package/dist/web/assets/AgentConversations-DOuZgUeA.js +0 -2
  137. package/dist/web/assets/AgentConversations-DS8h3yKn.css +0 -1
  138. package/dist/web/assets/AgentHand-eGsXoGSq.js +0 -1
  139. package/dist/web/assets/AgentName-Dtmu9Ff5.js +0 -1
  140. package/dist/web/assets/AgentProfilePage-D8Tvayn7.js +0 -1
  141. package/dist/web/assets/AgentsDirectoryPage-BTEfKZjF.js +0 -1
  142. package/dist/web/assets/ConnectionsPage-C1w99yN3.css +0 -1
  143. package/dist/web/assets/ConnectionsPage-DwOIcvl9.js +0 -1
  144. package/dist/web/assets/EmployeeRecruitDialog-BrIMRblq.css +0 -1
  145. package/dist/web/assets/EmployeeRecruitDialog-D11blfwU.js +0 -1
  146. package/dist/web/assets/EmployeeWorkbenchPage-BcEzYt65.css +0 -1
  147. package/dist/web/assets/EmployeeWorkbenchPage-CK2BAoGt.js +0 -3
  148. package/dist/web/assets/GrowthLoading-KTjE2e50.js +0 -1
  149. package/dist/web/assets/KnowledgeEditDialog-B3x8vs3c.css +0 -1
  150. package/dist/web/assets/KnowledgeEditDialog-n9trHQ5d.js +0 -1
  151. package/dist/web/assets/KnowledgeInspector-BcNjgw9H.js +0 -1
  152. package/dist/web/assets/KnowledgeOrganizerPage-BzgxP-nG.css +0 -1
  153. package/dist/web/assets/KnowledgeOrganizerPage-DMBpGxLr.js +0 -1
  154. package/dist/web/assets/KnowledgePage-POkCMW8f.js +0 -1
  155. package/dist/web/assets/KnowledgeWorkspace-2FvQadhB.js +0 -4
  156. package/dist/web/assets/KnowledgeWorkspace-sb8OJ97I.css +0 -1
  157. package/dist/web/assets/NotFoundPage-BCMZbeB8.js +0 -1
  158. package/dist/web/assets/OverviewPage-0CFWCG9-.css +0 -1
  159. package/dist/web/assets/OverviewPage-0mYifpjp.js +0 -1
  160. package/dist/web/assets/PersonalProfilePage-Chv2a2pi.css +0 -1
  161. package/dist/web/assets/PersonalProfilePage-JW6Muix6.js +0 -1
  162. package/dist/web/assets/PersonalProjectsPage-CRbi64JP.js +0 -1
  163. package/dist/web/assets/ProjectAgentFirstTask-6afJ8HUe.css +0 -1
  164. package/dist/web/assets/ProjectAgentFirstTask-DJ0wTbh8.js +0 -1
  165. package/dist/web/assets/ProjectAgentsPage-BgQLIpxg.css +0 -1
  166. package/dist/web/assets/ProjectAgentsPage-CQemHFxy.js +0 -12
  167. package/dist/web/assets/ProjectScopePicker-BwB7gK-7.js +0 -1
  168. package/dist/web/assets/PublicProjectsPage-CB272Sq-.js +0 -1
  169. package/dist/web/assets/ReviewPage-CelTZU4C.js +0 -1
  170. package/dist/web/assets/RoundtablesPage-ByVeZlgC.js +0 -2
  171. package/dist/web/assets/RoundtablesPage-DCbIBnBq.css +0 -1
  172. package/dist/web/assets/SearchableSelect-f7EHbyDL.js +0 -1
  173. package/dist/web/assets/SettingsPage-5g7mRN2F.css +0 -1
  174. package/dist/web/assets/SettingsPage-nAijmm5f.js +0 -1
  175. package/dist/web/assets/SharedJefaReportPage-CjpJ9bkk.js +0 -1
  176. package/dist/web/assets/UiDisclosure-CWlC91Zt.js +0 -1
  177. package/dist/web/assets/WritingTastePage-DaHWY61P.js +0 -1
  178. package/dist/web/assets/WritingTastePage-SMGF39mf.css +0 -1
  179. package/dist/web/assets/agent-profile-CBvJHGjt.css +0 -1
  180. package/dist/web/assets/client-B2cRl-7c.js +0 -1
  181. package/dist/web/assets/copy-D2LqwtPZ.js +0 -1
  182. package/dist/web/assets/index-CNp0tRNp.css +0 -1
  183. package/dist/web/assets/index-UygXwZDZ.js +0 -5
  184. package/dist/web/assets/shared-report-DEK9wvhl.js +0 -1
  185. package/dist/web/assets/useModalDialog-D8tiJfbc.js +0 -1
  186. package/docs/roundtable-beta-testing.md +0 -102
  187. package/docs/roundtable-beta-validation.md +0 -133
  188. package/docs/roundtable-research.md +0 -142
  189. package/src/agents/a2a/roundtable-participant.js +0 -374
  190. package/src/agents/claude-code/roundtable-participant.js +0 -24
  191. package/src/agents/codex/roundtable-config.js +0 -32
  192. package/src/agents/codex/roundtable-participant.js +0 -34
  193. package/src/agents/grok/roundtable-participant.js +0 -185
  194. package/src/agents/pi/roundtable-participant.js +0 -193
  195. package/src/agents/pi/roundtable-result-format.js +0 -183
  196. package/src/cli/roundtable-command.js +0 -79
  197. package/src/roundtables/application.js +0 -69
  198. package/src/roundtables/domain.js +0 -181
  199. package/src/roundtables/http-router.js +0 -56
  200. package/src/roundtables/mcp-server.js +0 -48
  201. package/src/roundtables/participant-registry.js +0 -12
  202. package/src/roundtables/process.js +0 -70
  203. package/src/roundtables/result-contract.js +0 -47
  204. package/src/roundtables/server.js +0 -52
  205. package/src/roundtables/service.js +0 -396
  206. package/src/roundtables/store.js +0 -59
  207. package/src/roundtables/tool-contract.js +0 -41
  208. package/src/roundtables/turn-prompt.js +0 -24
  209. package/src/roundtables/worker.js +0 -85
  210. /package/dist/web/assets/{GrowthLoading-CIuQ1icy.css → client-CIuQ1icy.css} +0 -0
@@ -0,0 +1,218 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import { ApplicationError, ApplicationErrorCode } from '../app/application-error.js';
4
+ import { WAKE_CLIENTS, resolveClientCommand, sessionWorkingDirectory, wakeAgent } from './wake.js';
5
+
6
+ const CLIENT_LABELS = { claude_code: 'Claude Code', codex: 'Codex', cursor: 'Cursor', claude: 'Claude', other: 'Agent' };
7
+ const THREAD_MESSAGE_LIMIT = 24;
8
+ const NESTED_ASK_LIMIT = 3;
9
+
10
+ // Agents talk to each other directly: an ask wakes the recipient in its own
11
+ // client and returns its answer, or waits in its inbox for its next task.
12
+ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFactory = randomUUID,
13
+ locateCwd = sessionWorkingDirectory, clientAvailable = (client) => Boolean(resolveClientCommand(client)) }) {
14
+ const spaceId = () => app.config.personal.spaceId;
15
+ // Opened on first use: most MCP processes never touch the roundtable.
16
+ let opened = null;
17
+ const store = new Proxy({}, { get: (_target, property) => (opened ??= openStore())[property] });
18
+
19
+ async function sender({ taskContextToken, sourceApplication }) {
20
+ if (!taskContextToken) return { agent: null, client: sourceApplication ?? 'other', session: null, projectId: null };
21
+ const task = await app.taskContextRegistry.context(taskContextToken, sourceApplication);
22
+ return { agent: task.projectAgentId ? await agentById(task.projectAgentId) : null,
23
+ client: task.sourceApplication ?? sourceApplication, session: task.sessionId, projectId: task.personalProjectId };
24
+ }
25
+
26
+ async function agents() {
27
+ return (await app.listProjectAgents({ personalSpaceId: spaceId() }))
28
+ .filter((agent) => agent.profile.status === 'active');
29
+ }
30
+ async function agentById(agentId) {
31
+ return (await agents()).find((agent) => agent.agentId === agentId) ?? null;
32
+ }
33
+ async function recipient(to) {
34
+ const needle = String(to ?? '').trim().replace(/^@\{?|\}$/g, '');
35
+ const all = await agents();
36
+ const exact = all.find((agent) => agent.agentId === needle || agent.agentId === decodeURIComponent(needle));
37
+ if (exact) return exact;
38
+ const named = all.filter((agent) => [agent.profile.displayName, agent.profile.name]
39
+ .some((name) => name?.toLowerCase() === needle.toLowerCase()));
40
+ if (named.length === 1) return named[0];
41
+ throw validation(named.length
42
+ ? `More than one Agent is named "${needle}"; use its agentId: ${named.map((agent) => agent.agentId).join(', ')}`
43
+ : `No active Agent matches "${needle}". Call find_agents first.`);
44
+ }
45
+
46
+ async function conversations(agent, projectId) {
47
+ const sessions = await app.personal.recentAgentSessions({ personalSpaceId: spaceId(), agentId: agent.agentId,
48
+ personalProjectId: projectId ?? null, limit: 5 }).catch(() => []);
49
+ return sessions.filter((session) => WAKE_CLIENTS.includes(session.source_application))
50
+ .map((session) => ({ client: session.source_application, sessionId: session.session_id,
51
+ projectId: session.personal_project_id, lastActive: session.last_active }));
52
+ }
53
+
54
+ async function findAgents({ query = null, personalProjectId = null } = {}) {
55
+ const needle = query?.trim().toLowerCase();
56
+ const matches = (await agents()).filter((agent) => !needle || [agent.agentId, agent.profile.displayName,
57
+ agent.profile.name, agent.profile.responsibility, ...(agent.profile.capabilities ?? [])]
58
+ .some((value) => value?.toLowerCase().includes(needle)));
59
+ return { agents: await Promise.all(matches.slice(0, 20).map(async (agent) => ({
60
+ agentId: agent.agentId, name: label(agent), responsibility: agent.profile.responsibility,
61
+ clients: (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)),
62
+ recentConversations: await conversations(agent, personalProjectId)
63
+ }))),
64
+ guidance: 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer.' };
65
+ }
66
+
67
+ // Ordered attempts: the chosen conversation (or the Agent's latest one), then a
68
+ // fresh session with the Agent's memory in that client and in its other clients.
69
+ async function deliveryTargets(agent, conversation, projectId, projectPath) {
70
+ const allowed = (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client) && clientAvailable(client));
71
+ const explicit = Boolean(conversation) && !['auto', 'new'].includes(conversation);
72
+ let resumable = null;
73
+ if (conversation !== 'new') {
74
+ const known = await conversations(agent, null);
75
+ const candidates = explicit
76
+ ? known.filter((session) => session.sessionId === conversation)
77
+ : known.filter((session) => !projectId || session.projectId === projectId);
78
+ for (const session of candidates) {
79
+ if (!allowed.includes(session.client)) continue;
80
+ const cwd = locateCwd(session.client, session.sessionId);
81
+ if (cwd) { resumable = { client: session.client, sessionId: session.sessionId, cwd }; break; }
82
+ }
83
+ if (explicit && !resumable) throw validation('That conversation cannot be resumed on this machine');
84
+ }
85
+ const freshClients = resumable
86
+ ? [resumable.client, ...(explicit ? [] : allowed.filter((client) => client !== resumable.client))]
87
+ : WAKE_CLIENTS.filter((client) => allowed.includes(client));
88
+ const fresh = freshClients.map((client) => ({ client, sessionId: null,
89
+ cwd: client === resumable?.client ? resumable.cwd : projectPath ?? resumable?.cwd ?? process.cwd() }));
90
+ return resumable ? [resumable, ...fresh] : fresh;
91
+ }
92
+
93
+ async function messageAgent({ taskContextToken = null, sourceApplication, to, body, threadId = null,
94
+ conversation = 'auto', wait = true, timeoutSeconds = 300, projectPath = null } = {}) {
95
+ const from = await sender({ taskContextToken, sourceApplication });
96
+ const target = await recipient(to);
97
+ if (from.agent?.agentId === target.agentId && !threadId) throw validation('An Agent cannot open a thread with itself');
98
+ let thread = threadId ? store.getThread(threadId) : null;
99
+ if (threadId && (!thread || thread.space_id !== spaceId())) throw validation('Thread not found');
100
+ const history = thread ? store.messages(thread.id) : [];
101
+ if (history.length >= THREAD_MESSAGE_LIMIT) throw validation('This thread reached its message limit; start a new one');
102
+ thread ??= store.createThread({ id: idFactory(), spaceId: spaceId(), projectId: from.projectId,
103
+ subject: body.trim().split(/\r?\n/)[0].slice(0, 80) });
104
+ const ask = store.appendMessage(thread.id, { id: idFactory(), kind: 'ask', body, status: 'queued',
105
+ from: { agentId: from.agent?.agentId, name: from.agent ? label(from.agent) : CLIENT_LABELS[from.client] ?? from.client,
106
+ client: from.client, session: from.session },
107
+ to: { agentId: target.agentId, name: label(target) } });
108
+ const nested = history.filter((message) => message.kind === 'ask' && message.status === 'delivering').length;
109
+ if (!wait || nested >= NESTED_ASK_LIMIT) return queued(thread, ask, target, nested ? 'nested_limit' : 'not_waiting');
110
+ const attempts = await deliveryTargets(target, conversation, from.projectId, projectPath);
111
+ if (!attempts.length) return queued(thread, ask, target, 'no_client');
112
+ const prompt = wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: from.client });
113
+ const timeoutMs = Math.min(Math.max(timeoutSeconds, 30), 900) * 1000;
114
+ const unusable = new Set();
115
+ let failure = 'wake_failed';
116
+ for (const delivery of attempts) {
117
+ if (unusable.has(delivery.client)) continue;
118
+ store.updateMessage(ask.id, { status: 'delivering', toClient: delivery.client, toSession: delivery.sessionId });
119
+ let answer;
120
+ try {
121
+ answer = await wake({ client: delivery.client, sessionId: delivery.sessionId, cwd: delivery.cwd, timeoutMs, prompt });
122
+ } catch (error) {
123
+ failure = error.code ?? 'wake_failed';
124
+ // A missing or logged-out client cannot answer from any session. A conversation
125
+ // open in its app, or a sub-agent thread, still allows a new session.
126
+ if (['client_login_required', 'client_unavailable'].includes(failure)) unusable.add(delivery.client);
127
+ // A timeout already used the caller's wait.
128
+ if (failure === 'wake_timeout') break;
129
+ continue;
130
+ }
131
+ const via = `${delivery.client}:${delivery.sessionId ? 'resume' : 'new'}`;
132
+ store.updateMessage(ask.id, { status: 'answered', via });
133
+ const reply = store.appendMessage(thread.id, { id: idFactory(), kind: 'reply', body: answer.body, status: 'sent',
134
+ inReplyTo: ask.id, via, from: { agentId: target.agentId, name: label(target), client: delivery.client,
135
+ session: answer.sessionId }, to: { agentId: from.agent?.agentId, name: ask.from_name, client: from.client, session: from.session } });
136
+ return { status: 'answered', threadId: thread.id, messageId: ask.id, recipient: { agentId: target.agentId, name: label(target) },
137
+ via, reply: reply.body };
138
+ }
139
+ store.updateMessage(ask.id, { status: 'queued', error: failure });
140
+ return queued(thread, ask, target, failure);
141
+ }
142
+
143
+ async function readMessages({ taskContextToken, sourceApplication }) {
144
+ const me = await sender({ taskContextToken, sourceApplication });
145
+ if (!me.agent) return { messages: [] };
146
+ return { messages: store.pendingFor(spaceId(), me.agent.agentId).map(inboxItem),
147
+ guidance: 'Answer each with reply_agent_message.' };
148
+ }
149
+
150
+ async function replyMessage({ taskContextToken, sourceApplication, messageId, body }) {
151
+ const me = await sender({ taskContextToken, sourceApplication });
152
+ const ask = store.getMessage(messageId);
153
+ if (!ask || ask.kind !== 'ask' || !me.agent || ask.to_agent !== me.agent.agentId) throw validation('No such message for this Agent');
154
+ if (ask.status === 'answered') throw validation('This message was already answered');
155
+ store.updateMessage(ask.id, { status: 'answered', via: `${me.client}:inbox` });
156
+ store.appendMessage(ask.thread_id, { id: idFactory(), kind: 'reply', body, status: 'sent', inReplyTo: ask.id,
157
+ via: `${me.client}:inbox`, from: { agentId: me.agent.agentId, name: label(me.agent), client: me.client, session: me.session },
158
+ to: { agentId: ask.from_agent, name: ask.from_name, client: ask.from_client, session: ask.from_session } });
159
+ return { status: 'sent', threadId: ask.thread_id };
160
+ }
161
+
162
+ function threads({ limit = 50 } = {}) {
163
+ return { threads: store.listThreads(spaceId(), { limit }).map((thread) => {
164
+ const messages = store.messages(thread.id);
165
+ const people = new Map();
166
+ for (const message of messages) {
167
+ for (const [agentId, name, client] of [[message.from_agent, message.from_name, message.from_client], [message.to_agent, message.to_name, message.to_client]]) {
168
+ const key = agentId ?? name;
169
+ if (key && !people.has(key)) people.set(key, { agentId, name, client });
170
+ }
171
+ }
172
+ const last = messages.at(-1);
173
+ return { id: thread.id, subject: thread.subject, projectId: thread.project_id, updatedAt: thread.updated_at,
174
+ messageCount: messages.length, participants: [...people.values()],
175
+ waiting: messages.some((message) => message.kind === 'ask' && message.status !== 'answered'),
176
+ last: last ? { from: last.from_name, body: last.body.slice(0, 160) } : null };
177
+ }) };
178
+ }
179
+
180
+ function thread({ threadId }) {
181
+ const record = store.getThread(threadId);
182
+ if (!record || record.space_id !== spaceId()) throw validation('Thread not found');
183
+ return { id: record.id, subject: record.subject, projectId: record.project_id, createdAt: record.created_at,
184
+ messages: store.messages(record.id).map((message) => ({ id: message.id, kind: message.kind, status: message.status,
185
+ body: message.body, inReplyTo: message.in_reply_to, via: message.via, error: message.error, createdAt: message.created_at,
186
+ from: { agentId: message.from_agent, name: message.from_name, client: message.from_client },
187
+ to: { agentId: message.to_agent, name: message.to_name, client: message.to_client } })) };
188
+ }
189
+
190
+ return { findAgents, messageAgent, readMessages, replyMessage, threads, thread,
191
+ pending: (agentId) => store.pendingFor(spaceId(), agentId).map(inboxItem),
192
+ close: () => opened?.close() };
193
+ }
194
+
195
+ function queued(thread, ask, target, reason) {
196
+ return { status: 'queued', threadId: thread.id, messageId: ask.id, reason,
197
+ recipient: { agentId: target.agentId, name: label(target) },
198
+ guidance: `${label(target)} will see this message when it next starts a task and answer with reply_agent_message.` };
199
+ }
200
+
201
+ function inboxItem(message) {
202
+ return { messageId: message.id, threadId: message.thread_id, from: message.from_name,
203
+ fromClient: message.from_client, body: message.body, createdAt: message.created_at };
204
+ }
205
+
206
+ function wakePrompt({ thread, target, senderName, senderClient, ask }) {
207
+ return `@{${encodeURIComponent(target.agentId)}} [FULI 圆桌 · ${thread.id}]\n`
208
+ + `${senderName}(${CLIENT_LABELS[senderClient] ?? senderClient})发来消息:\n\n${ask.body}\n\n`
209
+ + `请直接回答对方,你的最终回复会原样转达。需要再问其他 Agent 时,调用 message_agent 并带上 threadId="${thread.id}"。`;
210
+ }
211
+
212
+ function label(agent) {
213
+ return agent.profile.displayName || agent.profile.name || agent.agentId;
214
+ }
215
+
216
+ function validation(message) {
217
+ return new ApplicationError(ApplicationErrorCode.VALIDATION, message);
218
+ }
@@ -0,0 +1,86 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+
5
+ // Every Agent-to-Agent exchange is kept as a thread of messages so people can
6
+ // see who talked to whom, through which client, and what was said. The file
7
+ // is shared by the console and every client's MCP process.
8
+ export function createRoundtableStore(path = ':memory:') {
9
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
10
+ const database = new DatabaseSync(path);
11
+ database.exec(`
12
+ PRAGMA journal_mode = WAL;
13
+ PRAGMA busy_timeout = 5000;
14
+ CREATE TABLE IF NOT EXISTS threads (
15
+ id TEXT PRIMARY KEY, space_id TEXT NOT NULL, project_id TEXT, subject TEXT NOT NULL,
16
+ created_at TEXT NOT NULL, updated_at TEXT NOT NULL
17
+ );
18
+ CREATE TABLE IF NOT EXISTS messages (
19
+ id TEXT PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id), seq INTEGER NOT NULL,
20
+ kind TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL, in_reply_to TEXT,
21
+ from_agent TEXT, from_name TEXT, from_client TEXT, from_session TEXT,
22
+ to_agent TEXT, to_name TEXT, to_client TEXT, to_session TEXT,
23
+ via TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
24
+ UNIQUE(thread_id, seq)
25
+ );
26
+ CREATE INDEX IF NOT EXISTS messages_inbox ON messages(to_agent, status);
27
+ CREATE INDEX IF NOT EXISTS threads_recent ON threads(space_id, updated_at);
28
+ `);
29
+ const now = () => new Date().toISOString();
30
+ const transaction = (work) => {
31
+ database.exec('BEGIN IMMEDIATE');
32
+ try { const result = work(); database.exec('COMMIT'); return result; }
33
+ catch (error) { database.exec('ROLLBACK'); throw error; }
34
+ };
35
+
36
+ return {
37
+ createThread({ id, spaceId, projectId = null, subject }) {
38
+ const at = now();
39
+ database.prepare('INSERT INTO threads(id, space_id, project_id, subject, created_at, updated_at) VALUES(?,?,?,?,?,?)')
40
+ .run(id, spaceId, projectId, subject, at, at);
41
+ return this.getThread(id);
42
+ },
43
+ getThread(id) {
44
+ return database.prepare('SELECT * FROM threads WHERE id = ?').get(id) ?? null;
45
+ },
46
+ listThreads(spaceId, { projectId = null, limit = 50 } = {}) {
47
+ return database.prepare(`SELECT t.*, (SELECT count(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
48
+ FROM threads t WHERE t.space_id = ? AND (? IS NULL OR t.project_id = ?)
49
+ ORDER BY t.updated_at DESC LIMIT ?`).all(spaceId, projectId, projectId, limit);
50
+ },
51
+ appendMessage(threadId, message) {
52
+ return transaction(() => {
53
+ const at = now();
54
+ const seq = (database.prepare('SELECT coalesce(max(seq), 0) AS seq FROM messages WHERE thread_id = ?').get(threadId).seq) + 1;
55
+ database.prepare(`INSERT INTO messages(id, thread_id, seq, kind, body, status, in_reply_to,
56
+ from_agent, from_name, from_client, from_session, to_agent, to_name, to_client, to_session,
57
+ via, error, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
58
+ message.id, threadId, seq, message.kind, message.body, message.status, message.inReplyTo ?? null,
59
+ message.from?.agentId ?? null, message.from?.name ?? null, message.from?.client ?? null, message.from?.session ?? null,
60
+ message.to?.agentId ?? null, message.to?.name ?? null, message.to?.client ?? null, message.to?.session ?? null,
61
+ message.via ?? null, null, at, at);
62
+ database.prepare('UPDATE threads SET updated_at = ? WHERE id = ?').run(at, threadId);
63
+ return this.getMessage(message.id);
64
+ });
65
+ },
66
+ updateMessage(id, { status, via, error, toClient, toSession }) {
67
+ database.prepare(`UPDATE messages SET status = coalesce(?, status), via = coalesce(?, via), error = ?,
68
+ to_client = coalesce(?, to_client), to_session = coalesce(?, to_session), updated_at = ? WHERE id = ?`)
69
+ .run(status ?? null, via ?? null, error ?? null, toClient ?? null, toSession ?? null, now(), id);
70
+ return this.getMessage(id);
71
+ },
72
+ getMessage(id) {
73
+ return database.prepare('SELECT * FROM messages WHERE id = ?').get(id) ?? null;
74
+ },
75
+ messages(threadId) {
76
+ return database.prepare('SELECT * FROM messages WHERE thread_id = ? ORDER BY seq').all(threadId);
77
+ },
78
+ // Questions waiting for this Agent that no delivery is currently answering.
79
+ pendingFor(spaceId, agentId, limit = 5) {
80
+ return database.prepare(`SELECT m.* FROM messages m JOIN threads t ON t.id = m.thread_id
81
+ WHERE t.space_id = ? AND m.to_agent = ? AND m.kind = 'ask' AND m.status = 'queued'
82
+ ORDER BY m.created_at LIMIT ?`).all(spaceId, agentId, limit);
83
+ },
84
+ close() { database.close(); }
85
+ };
86
+ }
@@ -0,0 +1,61 @@
1
+ import { ApplicationError } from '../app/application-error.js';
2
+ import { booleanSchema, integerSchema, objectSchema, stringSchema } from '../agent-tools/schema.js';
3
+
4
+ const text = (maxLength) => ({ ...stringSchema(), minLength: 1, maxLength });
5
+
6
+ export const ROUNDTABLE_TOOL_DEFINITIONS = [
7
+ {
8
+ name: 'find_agents',
9
+ title: 'ROUNDTABLE · Find Agents to talk to',
10
+ description: 'List active FULI Agents you can message directly, with their recent Claude Code / Codex conversations. Optional query matches name, responsibility or capability.',
11
+ inputSchema: objectSchema({ query: text(128), personalProjectId: text(128) })
12
+ },
13
+ {
14
+ name: 'message_agent',
15
+ title: 'ROUNDTABLE · Ask another Agent',
16
+ description: 'Send a message to another Agent, like asking a teammate or a sub-agent. By default Fuli wakes the recipient in its own client (resuming its latest conversation, or one from find_agents) read-only, and returns its answer in reply. If it cannot be woken the message waits in its inbox. Pass the current taskContextToken so the recipient knows who is asking, projectPath as the current working directory, and threadId to continue a thread. Every exchange is recorded for the user.',
17
+ inputSchema: objectSchema({
18
+ taskContextToken: text(160),
19
+ to: text(160),
20
+ body: text(16_000),
21
+ threadId: text(128),
22
+ conversation: text(128),
23
+ wait: booleanSchema(),
24
+ timeoutSeconds: integerSchema({ minimum: 30, maximum: 900 }),
25
+ projectPath: text(4096)
26
+ }, ['to', 'body'])
27
+ },
28
+ {
29
+ name: 'read_agent_messages',
30
+ title: 'ROUNDTABLE · Read messages for me',
31
+ description: 'Read messages other Agents sent to the current task\'s Agent that are still waiting for an answer.',
32
+ inputSchema: objectSchema({ taskContextToken: text(160) }, ['taskContextToken'])
33
+ },
34
+ {
35
+ name: 'reply_agent_message',
36
+ title: 'ROUNDTABLE · Reply to an Agent',
37
+ description: 'Answer a message from read_agent_messages or task-entry agent_messages. The reply is delivered to the asking Agent and recorded in the thread.',
38
+ inputSchema: objectSchema({ taskContextToken: text(160), messageId: text(128), body: text(16_000) },
39
+ ['taskContextToken', 'messageId', 'body'])
40
+ },
41
+ {
42
+ name: 'read_agent_thread',
43
+ title: 'ROUNDTABLE · Read a thread',
44
+ description: 'Read every message of one Agent conversation thread.',
45
+ inputSchema: objectSchema({ threadId: text(128) }, ['threadId'])
46
+ }
47
+ ];
48
+
49
+ export const ROUNDTABLE_TOOL_NAMES = ROUNDTABLE_TOOL_DEFINITIONS.map(({ name }) => name);
50
+
51
+ export function callRoundtableTool(roundtable, name, input) {
52
+ if (!roundtable) throw new ApplicationError('roundtable_unavailable', 'Agent roundtable needs a local Fuli installation');
53
+ switch (name) {
54
+ case 'find_agents': return roundtable.findAgents(input);
55
+ case 'message_agent': return roundtable.messageAgent(input);
56
+ case 'read_agent_messages': return roundtable.readMessages(input);
57
+ case 'reply_agent_message': return roundtable.replyMessage(input);
58
+ case 'read_agent_thread': return roundtable.thread(input);
59
+ default: throw new TypeError(`Unknown roundtable tool: ${name}`);
60
+ }
61
+ }
@@ -0,0 +1,99 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+
5
+ import { WAKE_CLIENTS, wakeAdapter } from '../agents/wake-registry.js';
6
+
7
+ // Wakes the recipient Agent in its own client, headlessly and read-only, and
8
+ // returns its final answer with that conversation's full context. Each client's
9
+ // command, arguments, session lookup and output format live in its adapter.
10
+ export { WAKE_CLIENTS };
11
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9-]{7,127}$/;
12
+
13
+ function adapterFor(client) {
14
+ const adapter = wakeAdapter(client);
15
+ if (!adapter) throw new TypeError(`Unsupported client: ${client}`);
16
+ return adapter;
17
+ }
18
+
19
+ export function resolveClientCommand(client, { env = process.env, platform = process.platform,
20
+ fileExists = existsSync, which = whichCommand } = {}) {
21
+ return wakeAdapter(client)?.resolveCommand({ env, platform, fileExists, which }) ?? null;
22
+ }
23
+
24
+ export function wakeArguments(client, { sessionId = null, cwd } = {}) {
25
+ if (sessionId !== null && !SESSION_ID.test(sessionId)) throw new TypeError('Invalid client session id');
26
+ return adapterFor(client).args({ sessionId, cwd });
27
+ }
28
+
29
+ // Clients only resume a conversation from the directory it started in.
30
+ export function sessionWorkingDirectory(client, sessionId, { home = homedir(), env = process.env } = {}) {
31
+ if (!SESSION_ID.test(sessionId)) return null;
32
+ return wakeAdapter(client)?.sessionDirectory(sessionId, { home, env }) ?? null;
33
+ }
34
+
35
+ export async function wakeAgent({ client, sessionId = null, cwd, prompt, timeoutMs = 300_000,
36
+ env = process.env, run = runProcess } = {}) {
37
+ const command = resolveClientCommand(client, { env });
38
+ if (!command) throw Object.assign(new Error(`${client} is not installed on this machine`), { code: 'client_unavailable' });
39
+ const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith('FULI_ROUNDTABLE_')));
40
+ const result = await run(command, wakeArguments(client, { sessionId, cwd }), { cwd, env: childEnv, input: prompt, timeoutMs });
41
+ return parseFinalAnswer(client, result);
42
+ }
43
+
44
+ export function parseFinalAnswer(client, { code, stdout }) {
45
+ const adapter = adapterFor(client);
46
+ const state = adapter.initialState();
47
+ let sessionId = null;
48
+ for (const line of stdout.split(/\r?\n/)) {
49
+ let event;
50
+ try { event = JSON.parse(line); } catch { continue; }
51
+ sessionId ??= event.thread_id ?? event.session_id ?? null;
52
+ adapter.readEvent(event, state);
53
+ if (event.error === 'authentication_failed') {
54
+ throw Object.assign(new Error(`${client} is not logged in on this machine`), { code: 'client_login_required' });
55
+ }
56
+ }
57
+ if (code !== 0 || state.failed || !state.completed || !state.body.trim()) {
58
+ throw Object.assign(new Error(`${client} did not return an answer`), { code: 'wake_failed' });
59
+ }
60
+ return { body: state.body.trim(), sessionId };
61
+ }
62
+
63
+ // Owns exactly one child process tree and never touches unrelated clients.
64
+ export function runProcess(command, args, { cwd, env, input, timeoutMs, maxOutputBytes = 4_000_000 }) {
65
+ return new Promise((resolve, reject) => {
66
+ const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true,
67
+ detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'ignore'] });
68
+ let output = '', bytes = 0, settled = false;
69
+ const kill = () => {
70
+ if (!child.pid) return;
71
+ if (process.platform === 'win32') spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).unref();
72
+ else { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already exited */ } }
73
+ };
74
+ const finish = (error, code) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ if (error) { kill(); reject(error); } else resolve({ code, stdout: output });
79
+ };
80
+ const timer = setTimeout(() => finish(Object.assign(new Error('The woken Agent did not answer in time'), { code: 'wake_timeout' })), timeoutMs);
81
+ child.stdout.on('data', (chunk) => {
82
+ bytes += chunk.length;
83
+ if (bytes > maxOutputBytes) finish(Object.assign(new Error('The woken Agent produced too much output'), { code: 'wake_failed' }));
84
+ else output += chunk.toString('utf8');
85
+ });
86
+ child.on('error', () => finish(Object.assign(new Error('The client could not be started'), { code: 'client_unavailable' })));
87
+ child.on('close', (code) => finish(null, code));
88
+ child.stdin.on('error', () => {});
89
+ child.stdin.end(input);
90
+ });
91
+ }
92
+
93
+ function whichCommand(name, platform) {
94
+ const result = spawnSync(platform === 'win32' ? 'where.exe' : 'which', [name], { encoding: 'utf8', windowsHide: true });
95
+ if (result.status !== 0) return null;
96
+ // On Windows only real executables can be spawned without a shell.
97
+ return result.stdout.split(/\r?\n/).map((line) => line.trim())
98
+ .find((line) => line && (platform !== 'win32' || /\.exe$/i.test(line))) ?? null;
99
+ }
@@ -13,9 +13,9 @@ export const AGENT_CONVERSATION_DEFINITIONS = [
13
13
  description: 'Explicitly attach the current task session to an earlier conversation of the SAME Agent and project, returning bounded recovery context. Select the intended Agent at task entry first. Does not transfer files or native runtime state.',
14
14
  inputSchema: objectSchema({ taskContextToken: { ...id, maxLength: 160 }, conversationId: id }, ['taskContextToken', 'conversationId']) },
15
15
  { name: 'get_agent_conversation_policy', title: 'READ · Conversation policy',
16
- description: 'Read idle archival days, recovery context budget and capture switch. Defaults: 7 inactive days and 2000 conservative UTF-8 byte units. Archival preserves originals.',
16
+ description: 'Read the compaction threshold, recovery context budget and capture switch. Defaults: fold older messages into a digest after 64 KB of unfolded text; recover 4000 conservative UTF-8 bytes. Raw messages are always retained.',
17
17
  inputSchema: objectSchema(scope, required) },
18
18
  { name: 'update_agent_conversation_policy', title: 'WRITE · Conversation policy',
19
- description: 'Configure this Agent and project conversation capture and recovery. No LLM runs during capture or archival. Raw data is retained; inactivity affects default recovery only.',
20
- inputSchema: objectSchema({ ...scope, idleDays: integerSchema({ minimum: 1, maximum: 365 }), contextBudget: integerSchema({ minimum: 512, maximum: 16000 }), enabled: booleanSchema() }, [...required, 'idleDays', 'contextBudget', 'enabled']) }
19
+ description: 'Configure this Agent and project conversation capture and recovery. No LLM runs during capture or compaction. Raw data is retained; compaction only shortens default recovery.',
20
+ inputSchema: objectSchema({ ...scope, compactAfterKb: integerSchema({ minimum: 8, maximum: 1024 }), contextBudget: integerSchema({ minimum: 512, maximum: 16000 }), enabled: booleanSchema() }, [...required, 'compactAfterKb', 'contextBudget', 'enabled']) }
21
21
  ];
@@ -305,7 +305,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
305
305
  {
306
306
  name: 'checkpoint_task_knowledge',
307
307
  title: 'LIFECYCLE · Finish knowledge review',
308
- description: 'Complete exactly one end-of-task knowledge review. An assigned employee must include workLog with a concise result summary and truthful status even with retain_nothing. Include agentMemory with the loaded revision and merged notes when durable role memory changed. Use capture_candidates only for a small durable batch supported by the task; captured Agent proposals remain pending unless the payload contains valid human or authoritative-source confirmation. Use retain_nothing when the turn produced no reusable knowledge. Never store raw transcripts, guesses, temporary logs, credentials, or disposable output.',
308
+ description: 'Complete exactly one end-of-task knowledge review. An assigned employee must include workLog with a concise result summary and truthful status even with retain_nothing. Include agentMemory with the loaded revision and merged notes when durable role memory changed. Use capture_candidates only for a small durable batch supported by the task; captured Agent proposals remain pending unless the payload contains valid human or authoritative-source confirmation. Use retain_nothing when the turn produced no reusable knowledge. Never store raw transcripts, guesses, temporary logs, credentials, or disposable output. List in collaborators the Agent IDs that actually contributed (employee tools called, coordinated workers, Roundtable participants); unknown or inactive IDs are ignored. End the final reply with the returned agent_receipt.markdown, which names the lead and those collaborators.',
309
309
  inputSchema: objectSchema({
310
310
  taskContextToken: id,
311
311
  disposition: enumSchema(['capture_candidates', 'retain_nothing']),
@@ -318,7 +318,8 @@ export const GRAPH_TOOL_DEFINITIONS = [
318
318
  agentMemory: objectSchema({
319
319
  expectedRevision: integerSchema({ minimum: 0 }),
320
320
  memory: workingMemorySchema
321
- }, ['expectedRevision', 'memory'])
321
+ }, ['expectedRevision', 'memory']),
322
+ collaborators: arraySchema(boundedString(128), { maxItems: 16 })
322
323
  }, ['taskContextToken', 'disposition', 'reason'])
323
324
  },
324
325
  {
@@ -1,9 +1,4 @@
1
1
  const UI_MUTATION_PARITY = Object.freeze([
2
- local('POST /api/roundtables', 'Creating rooms, granting private identity context and selecting execution permissions requires the local user.'),
3
- local('POST /api/roundtables/:roomId/invites', 'Seat invitations are local-user issued scoped capabilities.'),
4
- local('POST /api/roundtables/:roomId/revoke', 'Revoking participation requires the room owner.'),
5
- local('POST /api/roundtables/:roomId/control', 'Starting, changing limits, stopping or human acceptance requires the room owner.'),
6
- local('POST /api/roundtables/:roomId/messages', 'Human input must not be impersonated by an Agent.'),
7
2
  agent('POST /api/agent-conversations/query', 'list_agent_conversations', 'read_agent_conversation', 'get_agent_conversation_policy'),
8
3
  agent('PUT /api/agent-conversations/policy', 'update_agent_conversation_policy'),
9
4
  agent('POST /api/agent-attention', 'request_agent_attention'),
@@ -1,7 +1,7 @@
1
1
  import { GRAPH_TOOL_DEFINITIONS } from './agent-tools/graph-definitions.js';
2
2
  import { dispatchGraphTool } from './agent-tools/graph-handlers.js';
3
3
  import { runWithAgentRequestContext } from './app/agent-request-context.js';
4
- import { ROUNDTABLE_TOOL_DEFINITIONS, ROUNDTABLE_TOOL_NAMES, callRoundtableTool } from './roundtables/tool-contract.js';
4
+ import { ROUNDTABLE_TOOL_DEFINITIONS, ROUNDTABLE_TOOL_NAMES, callRoundtableTool } from './agent-roundtable/tools.js';
5
5
  import { ApplicationError } from './app/application-error.js';
6
6
 
7
7
  const TOOL_DEFINITIONS = [...GRAPH_TOOL_DEFINITIONS, ...ROUNDTABLE_TOOL_DEFINITIONS];
@@ -17,7 +17,7 @@ export function callAgentTool(app, name, input = {}, requestContext = null) {
17
17
  return runWithAgentRequestContext(
18
18
  requestContext,
19
19
  () => ROUNDTABLE_TOOL_NAMES.includes(name)
20
- ? callRoundtableTool(app.roundtables, name, input)
20
+ ? callRoundtableTool(app.roundtable, name, input)
21
21
  : dispatchGraphTool(app, name, input)
22
22
  );
23
23
  }
@@ -37,19 +37,7 @@ export async function claudeLifecycleOutput(event, input, invoke) {
37
37
  const check = await invoke('verify_task_checkpoint', { sessionId, ...source });
38
38
  const reason = boundedHookMessage(check?.reason);
39
39
  if (check?.decision !== 'block' || !reason.trim()) return {};
40
- if (input.stop_hook_active === true) {
41
- if (typeof check.task_context_token === 'string' && check.task_context_token.trim()) {
42
- await invoke('checkpoint_task_knowledge', {
43
- taskContextToken: check.task_context_token,
44
- disposition: 'retain_nothing',
45
- reason: 'Claude Code Stop hook fallback after one checkpoint continuation.',
46
- workLog: { status: 'incomplete', summary: 'The host stopped before the employee submitted a work summary. Review the prior task before resuming.' },
47
- ...source
48
- });
49
- }
50
- return {};
51
- }
52
- return { decision: 'block', reason };
40
+ return { systemMessage: reason };
53
41
  }
54
42
 
55
43
  export function claudeLifecycleUnavailable(event) {
@@ -80,7 +68,7 @@ export async function runClaudeLifecycleHook(args, dependencies = {}) {
80
68
  let sync = await syncConversationTranscript(app, input, 'claude_code', {
81
69
  normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
82
70
  }, guard);
83
- if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
71
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
84
72
  const entryGuard = sync.status === 'capture_disabled' ? null : guard;
85
73
  if (event === 'UserPromptSubmit' && entryGuard?.value) await entryGuard.write({ ...entryGuard.value, phase: 'beginning' });
86
74
  const output = await claudeLifecycleOutput(event, input, async (name, parameters) => {
@@ -91,12 +79,12 @@ export async function runClaudeLifecycleHook(args, dependencies = {}) {
91
79
  if (event === 'UserPromptSubmit') sync = await claimTranscriptBoundary(app, input, 'claude_code', sync, {
92
80
  normalize: normalizeClaudeRecord, verify: verifyClaudeTranscript
93
81
  }, entryGuard);
94
- if (event === 'UserPromptSubmit' && sync.entryBlocked) return { decision: 'block', reason: sync.reason };
82
+ if (event === 'UserPromptSubmit' && sync.entryBlocked) return { systemMessage: boundedHookMessage(sync.reason) };
95
83
  return withTranscriptNotice(output, sync);
96
84
  });
97
85
  } catch (error) {
98
86
  if (event !== 'UserPromptSubmit' || !app.getCapturePolicy?.().enabled || !input.transcript_path) throw error;
99
- return { decision: 'block', reason: error.code === 'EADDRINUSE'
87
+ return { systemMessage: error.code === 'EADDRINUSE'
100
88
  ? 'The session handoff lock is occupied. Retry after the current hook finishes; if it persists, start a new host session and resume the intended Agent.'
101
89
  : 'Fuli transcript handoff could not be completed safely. Retry task entry; no previous Agent context was supplied.' };
102
90
  }
@@ -127,7 +115,6 @@ async function main() {
127
115
  ? configured : 28000;
128
116
  const controller = new AbortController();
129
117
  let emitted = false;
130
- let guarded = false;
131
118
  const emit = (output, callback) => {
132
119
  if (emitted) return;
133
120
  emitted = true;
@@ -137,11 +124,11 @@ async function main() {
137
124
  // No prompt, transcript, path or raw exception is written to diagnostics.
138
125
  const deadline = setTimeout(() => {
139
126
  controller.abort();
140
- emit(guarded && event === 'UserPromptSubmit' ? { decision: 'block', reason: 'Fuli transcript handoff timed out. Retry task entry; the previous Agent context was not supplied.' } : claudeLifecycleUnavailable(event), () => process.exit(0));
127
+ emit(claudeLifecycleUnavailable(event), () => process.exit(0));
141
128
  }, timeoutMs);
142
129
  const abort = setTimeout(() => controller.abort(), Math.max(1, timeoutMs - 1500));
143
130
  try {
144
- const output = await runClaudeLifecycleHook(args, { signal: controller.signal, onTranscriptGuard: () => { guarded = true; } });
131
+ const output = await runClaudeLifecycleHook(args, { signal: controller.signal });
145
132
  emit(output);
146
133
  } catch {
147
134
  emit(claudeLifecycleUnavailable(event));
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { firstField, listDirectory } from '../session-log.js';
5
+
6
+ // Wakes Claude Code headlessly. Resuming forks the session, so an open window is
7
+ // never written concurrently while the answer still has that conversation's context.
8
+ export const claudeCodeWake = Object.freeze({
9
+ id: 'claude_code',
10
+
11
+ resolveCommand({ env, which, platform }) {
12
+ return env.FULI_CLAUDE_BIN || which('claude', platform);
13
+ },
14
+
15
+ args({ sessionId }) {
16
+ return ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', 'dontAsk',
17
+ ...(sessionId ? ['--resume', sessionId, '--fork-session'] : [])];
18
+ },
19
+
20
+ // A session resumes only from the directory it started in.
21
+ sessionDirectory(sessionId, { home }) {
22
+ const root = join(home, '.claude', 'projects');
23
+ for (const project of listDirectory(root)) {
24
+ const file = join(root, project, `${sessionId}.jsonl`);
25
+ if (existsSync(file)) return firstField(file, (line) => line.cwd);
26
+ }
27
+ return null;
28
+ },
29
+
30
+ // stream-json ends with one `result` event carrying the final answer.
31
+ initialState: () => ({ body: '', completed: true, failed: false }),
32
+ readEvent(event, state) {
33
+ if (event.type !== 'result') return;
34
+ state.body = event.result ?? '';
35
+ state.failed ||= Boolean(event.is_error);
36
+ },
37
+ });