fuli-context 0.10.0 → 0.11.1

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 (212) hide show
  1. package/README.md +27 -30
  2. package/README.zh-CN.md +22 -26
  3. package/dist/web/assets/AboutPage-cW9hDghN.js +1 -0
  4. package/dist/web/assets/AgentConversations-CGznTWuV.js +2 -0
  5. package/dist/web/assets/AgentConversations-XqNEBY_2.css +1 -0
  6. package/dist/web/assets/AgentHand-DkyF4vgk.js +1 -0
  7. package/dist/web/assets/AgentName-C1mAVr-v.js +1 -0
  8. package/dist/web/assets/AgentProfilePage-pKUR0rfj.js +1 -0
  9. package/dist/web/assets/AgentsDirectoryPage-CUlbT2hD.js +1 -0
  10. package/dist/web/assets/AgentsDirectoryPage-yRWktiin.css +1 -0
  11. package/dist/web/assets/ConnectionsPage-CM9C-zEI.css +1 -0
  12. package/dist/web/assets/ConnectionsPage-CuDHIJXu.js +1 -0
  13. package/dist/web/assets/EmployeeRecruitDialog-CVbqCzHb.css +1 -0
  14. package/dist/web/assets/EmployeeRecruitDialog-D9QfGipd.js +1 -0
  15. package/dist/web/assets/EmployeeWorkbenchPage-D-c_Jm2z.css +1 -0
  16. package/dist/web/assets/EmployeeWorkbenchPage-DAtvGney.js +3 -0
  17. package/dist/web/assets/KnowledgeEditDialog-a-5rasG0.js +1 -0
  18. package/dist/web/assets/KnowledgeEditDialog-hT9znkoe.css +1 -0
  19. package/dist/web/assets/KnowledgeInspector-BGsqjNY2.js +1 -0
  20. package/dist/web/assets/KnowledgeOrganizerPage-BndgUsHS.js +1 -0
  21. package/dist/web/assets/KnowledgeOrganizerPage-DeL7Jp34.css +1 -0
  22. package/dist/web/assets/KnowledgePage-CQweO0YJ.js +1 -0
  23. package/dist/web/assets/KnowledgeWorkspace-BtUjKTLb.js +4 -0
  24. package/dist/web/assets/KnowledgeWorkspace-D4a7FcAb.css +1 -0
  25. package/dist/web/assets/NotFoundPage-BmQTTqQn.js +1 -0
  26. package/dist/web/assets/OverviewPage-By-fUzSG.css +1 -0
  27. package/dist/web/assets/OverviewPage-C73HyTsM.js +1 -0
  28. package/dist/web/assets/PersonalProfilePage-BMuOrppx.js +1 -0
  29. package/dist/web/assets/PersonalProfilePage-DrMDAAdy.css +1 -0
  30. package/dist/web/assets/PersonalProjectsPage-Cq9_kFvs.js +1 -0
  31. package/dist/web/assets/ProjectAgentFirstTask-Bi1ZsTC1.css +1 -0
  32. package/dist/web/assets/ProjectAgentFirstTask-CaHvOjkp.js +1 -0
  33. package/dist/web/assets/ProjectAgentsPage-DWGkgIsr.css +1 -0
  34. package/dist/web/assets/ProjectAgentsPage-WVcNdzVi.js +12 -0
  35. package/dist/web/assets/ProjectScopePicker-zAgiaZKL.js +1 -0
  36. package/dist/web/assets/PublicProjectsPage-DKamXDV9.css +1 -0
  37. package/dist/web/assets/PublicProjectsPage-DL093f65.js +1 -0
  38. package/dist/web/assets/ReviewPage-D_NIhPx8.js +1 -0
  39. package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +1 -0
  40. package/dist/web/assets/RoundtablesPage-CdVVyZE-.js +1 -0
  41. package/dist/web/assets/SettingsPage-BmYkLul4.css +1 -0
  42. package/dist/web/assets/SettingsPage-CbLaau8Q.js +1 -0
  43. package/dist/web/assets/SharedJefaReportPage-aygd6wvb.js +1 -0
  44. package/dist/web/assets/UiDialog-Q7fsJUoO.js +1 -0
  45. package/dist/web/assets/UiDisclosure-BS-muK5j.js +1 -0
  46. package/dist/web/assets/UiSegmented-CT28bxqw.js +1 -0
  47. package/dist/web/assets/UiSelect-Bv6R0yr1.css +1 -0
  48. package/dist/web/assets/UiSelect-DcGpPQQo.js +1 -0
  49. package/dist/web/assets/WritingTastePage-B_zYD1df.css +1 -0
  50. package/dist/web/assets/WritingTastePage-CYlwAFqV.js +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-D-qIFRIb.js} +1 -1
  53. package/dist/web/assets/{catalog-DN72452f.js → catalog-UDv854JN.js} +1 -1
  54. package/dist/web/assets/client-DLS5FZBI.js +1 -0
  55. package/dist/web/assets/{console-BtPqo79j.js → console-CqzwSo7a.js} +1 -1
  56. package/dist/web/assets/{conversation-launcher-settings-jkHRmWai.js → conversation-launcher-settings-DRoIz42V.js} +1 -1
  57. package/dist/web/assets/{i18n-D2CJkEYK.js → i18n-b14l2pQA.js} +3 -3
  58. package/dist/web/assets/{identity-B3zEw-1u.js → identity-BWbPmZZf.js} +1 -1
  59. package/dist/web/assets/index-D1OHIz4c.js +5 -0
  60. package/dist/web/assets/index-kPB0Cf8Q.css +1 -0
  61. package/dist/web/assets/{model-S7ehAjky.js → model-DupZy8Qb.js} +1 -1
  62. package/dist/web/assets/{personal-profile-graph--GG3Mizo.js → personal-profile-graph-BPTtMJTZ.js} +1 -1
  63. package/dist/web/assets/shared-report-BYnLhmCZ.js +1 -0
  64. package/dist/web/assets/{useAgentRoster-BAPKWZ9y.js → useAgentRoster-ep7SpKv1.js} +1 -1
  65. package/dist/web/assets/{useApi-CROJJdhE-BtFMXFjw.js → useApi-CROJJdhE-CEQAEuug.js} +1 -1
  66. package/dist/web/assets/{vue-router-DkaTtPn0.js → vue-router-B6ZjK-Br.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 +77 -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_context_models.py +5 -1
  76. package/graph-provider/fuli_graph/project_agent_delegations.py +118 -0
  77. package/graph-provider/fuli_graph/project_agent_employee_numbers.py +41 -0
  78. package/graph-provider/fuli_graph/project_agent_memory_routes.py +20 -0
  79. package/graph-provider/fuli_graph/project_agent_models.py +1 -0
  80. package/graph-provider/fuli_graph/project_agent_task_models.py +3 -0
  81. package/graph-provider/fuli_graph/project_agent_team_growth.py +2 -2
  82. package/graph-provider/fuli_graph/runtime.py +10 -76
  83. package/graph-provider/fuli_graph/store.py +2 -0
  84. package/graph-provider/fuli_graph/store_agent_conversations.py +60 -8
  85. package/graph-provider/fuli_graph/store_project_agent_context.py +52 -4
  86. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +16 -1
  87. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +3 -1
  88. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +3 -2
  89. package/graph-provider/fuli_graph/store_project_agent_tasks.py +12 -8
  90. package/graph-provider/fuli_graph/store_project_agents.py +56 -129
  91. package/graph-provider/fuli_graph/store_task_contexts.py +26 -0
  92. package/graph-provider/fuli_graph/system_agents.py +130 -0
  93. package/graph-provider/fuli_graph/system_hr_identity.py +1 -1
  94. package/graph-provider/fuli_graph/task_context_routes.py +10 -0
  95. package/graph-provider/pyproject.toml +1 -1
  96. package/npm-shrinkwrap.json +2 -2
  97. package/package.json +2 -2
  98. package/src/agent-roundtable/http.js +14 -0
  99. package/src/agent-roundtable/service.js +247 -0
  100. package/src/agent-roundtable/store.js +86 -0
  101. package/src/agent-roundtable/tools.js +61 -0
  102. package/src/agent-roundtable/wake.js +100 -0
  103. package/src/agent-tools/agent-conversation-definitions.js +3 -3
  104. package/src/agent-tools/graph-definitions.js +3 -2
  105. package/src/agent-tools/interface-catalog.js +0 -5
  106. package/src/agent-tools.js +2 -2
  107. package/src/agents/claude-code/wake.js +37 -0
  108. package/src/agents/codex/wake.js +47 -0
  109. package/src/agents/identity-receipt.js +35 -7
  110. package/src/agents/session-log.js +34 -0
  111. package/src/agents/wake-registry.js +14 -0
  112. package/src/cli/command-registry.js +0 -2
  113. package/src/cli/setup-command.js +2 -5
  114. package/src/cli/update-command.js +13 -2
  115. package/src/cli.js +0 -5
  116. package/src/employees/management-policy.js +4 -2
  117. package/src/employees/manifest.js +3 -0
  118. package/src/employees/recruitment.js +3 -2
  119. package/src/graphiti/agent-conversations.js +11 -9
  120. package/src/graphiti/agent-knowledge-workflows.js +34 -7
  121. package/src/graphiti/federated-application.js +17 -5
  122. package/src/graphiti/project-agent-mapping.js +2 -0
  123. package/src/graphiti/project-agent-task-entry.js +38 -8
  124. package/src/graphiti/project-agent-team-context.js +4 -1
  125. package/src/graphiti/project-path-context.js +7 -2
  126. package/src/graphiti/provider-client.js +17 -0
  127. package/src/graphiti/repository-project-registration.js +17 -0
  128. package/src/graphiti/task-entry-preferences.js +17 -4
  129. package/src/graphiti/task-entry-project-scope.js +67 -0
  130. package/src/http/api-router.js +5 -3
  131. package/src/http/error-mapping.js +0 -2
  132. package/src/mcp/instructions.js +5 -5
  133. package/src/mcp/session-id.js +1 -1
  134. package/src/mcp/tool-annotations.js +3 -4
  135. package/src/setup/agents.js +1 -41
  136. package/src/setup/graph-runtime.js +15 -2
  137. package/src/setup/session-skill.js +22 -2
  138. package/dist/web/assets/AboutPage-D-GyJNv1.js +0 -1
  139. package/dist/web/assets/AgentConversations-DOuZgUeA.js +0 -2
  140. package/dist/web/assets/AgentConversations-DS8h3yKn.css +0 -1
  141. package/dist/web/assets/AgentHand-eGsXoGSq.js +0 -1
  142. package/dist/web/assets/AgentName-Dtmu9Ff5.js +0 -1
  143. package/dist/web/assets/AgentProfilePage-D8Tvayn7.js +0 -1
  144. package/dist/web/assets/AgentsDirectoryPage-BTEfKZjF.js +0 -1
  145. package/dist/web/assets/ConnectionsPage-C1w99yN3.css +0 -1
  146. package/dist/web/assets/ConnectionsPage-DwOIcvl9.js +0 -1
  147. package/dist/web/assets/EmployeeRecruitDialog-BrIMRblq.css +0 -1
  148. package/dist/web/assets/EmployeeRecruitDialog-D11blfwU.js +0 -1
  149. package/dist/web/assets/EmployeeWorkbenchPage-BcEzYt65.css +0 -1
  150. package/dist/web/assets/EmployeeWorkbenchPage-CK2BAoGt.js +0 -3
  151. package/dist/web/assets/GrowthLoading-KTjE2e50.js +0 -1
  152. package/dist/web/assets/KnowledgeEditDialog-B3x8vs3c.css +0 -1
  153. package/dist/web/assets/KnowledgeEditDialog-n9trHQ5d.js +0 -1
  154. package/dist/web/assets/KnowledgeInspector-BcNjgw9H.js +0 -1
  155. package/dist/web/assets/KnowledgeOrganizerPage-BzgxP-nG.css +0 -1
  156. package/dist/web/assets/KnowledgeOrganizerPage-DMBpGxLr.js +0 -1
  157. package/dist/web/assets/KnowledgePage-POkCMW8f.js +0 -1
  158. package/dist/web/assets/KnowledgeWorkspace-2FvQadhB.js +0 -4
  159. package/dist/web/assets/KnowledgeWorkspace-sb8OJ97I.css +0 -1
  160. package/dist/web/assets/NotFoundPage-BCMZbeB8.js +0 -1
  161. package/dist/web/assets/OverviewPage-0CFWCG9-.css +0 -1
  162. package/dist/web/assets/OverviewPage-0mYifpjp.js +0 -1
  163. package/dist/web/assets/PersonalProfilePage-Chv2a2pi.css +0 -1
  164. package/dist/web/assets/PersonalProfilePage-JW6Muix6.js +0 -1
  165. package/dist/web/assets/PersonalProjectsPage-CRbi64JP.js +0 -1
  166. package/dist/web/assets/ProjectAgentFirstTask-6afJ8HUe.css +0 -1
  167. package/dist/web/assets/ProjectAgentFirstTask-DJ0wTbh8.js +0 -1
  168. package/dist/web/assets/ProjectAgentsPage-CPgKVLgg.js +0 -12
  169. package/dist/web/assets/ProjectAgentsPage-DeuRkMp1.css +0 -1
  170. package/dist/web/assets/ProjectScopePicker-BwB7gK-7.js +0 -1
  171. package/dist/web/assets/PublicProjectsPage-CB272Sq-.js +0 -1
  172. package/dist/web/assets/ReviewPage-CelTZU4C.js +0 -1
  173. package/dist/web/assets/RoundtablesPage-C9dXfWCl.js +0 -2
  174. package/dist/web/assets/RoundtablesPage-DCbIBnBq.css +0 -1
  175. package/dist/web/assets/SearchableSelect-f7EHbyDL.js +0 -1
  176. package/dist/web/assets/SettingsPage-5g7mRN2F.css +0 -1
  177. package/dist/web/assets/SettingsPage-nAijmm5f.js +0 -1
  178. package/dist/web/assets/SharedJefaReportPage-CjpJ9bkk.js +0 -1
  179. package/dist/web/assets/UiDisclosure-CWlC91Zt.js +0 -1
  180. package/dist/web/assets/WritingTastePage-DaHWY61P.js +0 -1
  181. package/dist/web/assets/WritingTastePage-SMGF39mf.css +0 -1
  182. package/dist/web/assets/agent-profile-CBvJHGjt.css +0 -1
  183. package/dist/web/assets/client-B2cRl-7c.js +0 -1
  184. package/dist/web/assets/copy-D2LqwtPZ.js +0 -1
  185. package/dist/web/assets/index-CNp0tRNp.css +0 -1
  186. package/dist/web/assets/index-ulISYH02.js +0 -5
  187. package/dist/web/assets/shared-report-DEK9wvhl.js +0 -1
  188. package/dist/web/assets/useModalDialog-D8tiJfbc.js +0 -1
  189. package/docs/roundtable.md +0 -46
  190. package/src/agents/a2a/roundtable-participant.js +0 -374
  191. package/src/agents/claude-code/roundtable-participant.js +0 -24
  192. package/src/agents/codex/roundtable-config.js +0 -32
  193. package/src/agents/codex/roundtable-participant.js +0 -34
  194. package/src/agents/grok/roundtable-participant.js +0 -185
  195. package/src/agents/pi/roundtable-participant.js +0 -193
  196. package/src/agents/pi/roundtable-result-format.js +0 -183
  197. package/src/cli/roundtable-command.js +0 -79
  198. package/src/roundtables/application.js +0 -69
  199. package/src/roundtables/domain.js +0 -181
  200. package/src/roundtables/http-router.js +0 -58
  201. package/src/roundtables/mcp-server.js +0 -55
  202. package/src/roundtables/participant-registry.js +0 -12
  203. package/src/roundtables/process.js +0 -70
  204. package/src/roundtables/profiles.js +0 -84
  205. package/src/roundtables/result-contract.js +0 -47
  206. package/src/roundtables/server.js +0 -52
  207. package/src/roundtables/service.js +0 -480
  208. package/src/roundtables/store.js +0 -63
  209. package/src/roundtables/tool-contract.js +0 -58
  210. package/src/roundtables/turn-prompt.js +0 -24
  211. package/src/roundtables/worker.js +0 -85
  212. /package/dist/web/assets/{GrowthLoading-CIuQ1icy.css → client-CIuQ1icy.css} +0 -0
@@ -0,0 +1,247 @@
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
+ env = process.env, 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 number = needle.match(/^(?:FLA\s*)?(\d{6,})$/i)?.[1];
39
+ const numbered = number && all.find((agent) => agent.employeeNumber === number);
40
+ if (numbered) return numbered;
41
+ const named = all.filter((agent) => [agent.profile.displayName, agent.profile.name]
42
+ .some((name) => name?.toLowerCase() === needle.toLowerCase()));
43
+ if (named.length === 1) return named[0];
44
+ throw validation(named.length
45
+ ? `More than one Agent is named "${needle}"; use its agentId: ${named.map((agent) => agent.agentId).join(', ')}`
46
+ : `No active Agent matches "${needle}". Call find_agents first.`);
47
+ }
48
+
49
+ async function conversations(agent, projectId) {
50
+ const sessions = await app.personal.recentAgentSessions({ personalSpaceId: spaceId(), agentId: agent.agentId,
51
+ personalProjectId: projectId ?? null, limit: 5 }).catch(() => []);
52
+ return sessions.filter((session) => WAKE_CLIENTS.includes(session.source_application))
53
+ .map((session) => ({ client: session.source_application, sessionId: session.session_id,
54
+ projectId: session.personal_project_id, lastActive: session.last_active }));
55
+ }
56
+
57
+ async function findAgents({ query = null, personalProjectId = null } = {}) {
58
+ const needle = query?.trim().toLowerCase();
59
+ const matches = (await agents()).filter((agent) => !needle || [agent.agentId,
60
+ agent.employeeNumber ? `FLA ${agent.employeeNumber}` : null, agent.profile.displayName,
61
+ agent.profile.name, agent.profile.responsibility, ...(agent.profile.capabilities ?? [])]
62
+ .some((value) => value?.toLowerCase().includes(needle)));
63
+ return { agents: await Promise.all(matches.slice(0, 20).map(async (agent) => ({
64
+ agentId: agent.agentId, employeeNumber: agent.employeeNumber, name: label(agent), responsibility: agent.profile.responsibility,
65
+ clients: (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)),
66
+ recentConversations: await conversations(agent, personalProjectId)
67
+ }))),
68
+ guidance: 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer.' };
69
+ }
70
+
71
+ // Ordered attempts: the chosen conversation (or the Agent's latest one), then a
72
+ // fresh session with the Agent's memory in that client and in its other clients.
73
+ async function deliveryTargets(agent, conversation, projectId, projectPath) {
74
+ const allowed = (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client) && clientAvailable(client));
75
+ const explicit = Boolean(conversation) && !['auto', 'new'].includes(conversation);
76
+ let resumable = null;
77
+ if (conversation !== 'new') {
78
+ const known = await conversations(agent, null);
79
+ const candidates = explicit
80
+ ? known.filter((session) => session.sessionId === conversation)
81
+ : known.filter((session) => !projectId || session.projectId === projectId);
82
+ for (const session of candidates) {
83
+ if (!allowed.includes(session.client)) continue;
84
+ const cwd = locateCwd(session.client, session.sessionId);
85
+ if (cwd) { resumable = { client: session.client, sessionId: session.sessionId, cwd }; break; }
86
+ }
87
+ if (explicit && !resumable) throw validation('That conversation cannot be resumed on this machine');
88
+ }
89
+ const freshClients = resumable
90
+ ? [resumable.client, ...(explicit ? [] : allowed.filter((client) => client !== resumable.client))]
91
+ : WAKE_CLIENTS.filter((client) => allowed.includes(client));
92
+ const fresh = freshClients.map((client) => ({ client, sessionId: null,
93
+ cwd: client === resumable?.client ? resumable.cwd : projectPath ?? resumable?.cwd ?? process.cwd() }));
94
+ return resumable ? [resumable, ...fresh] : fresh;
95
+ }
96
+
97
+ async function messageAgent({ taskContextToken = null, sourceApplication, to, body, threadId = null,
98
+ conversation = 'auto', wait = true, timeoutSeconds = 300, projectPath = null } = {}) {
99
+ const from = await sender({ taskContextToken, sourceApplication });
100
+ const target = await recipient(to);
101
+ if (from.agent?.agentId === target.agentId && !threadId) throw validation('An Agent cannot open a thread with itself');
102
+ let thread = threadId ? store.getThread(threadId) : null;
103
+ if (threadId && (!thread || thread.space_id !== spaceId())) throw validation('Thread not found');
104
+ const history = thread ? store.messages(thread.id) : [];
105
+ if (history.length >= THREAD_MESSAGE_LIMIT) throw validation('This thread reached its message limit; start a new one');
106
+ thread ??= store.createThread({ id: idFactory(), spaceId: spaceId(), projectId: from.projectId,
107
+ subject: body.trim().split(/\r?\n/)[0].slice(0, 80) });
108
+ const ask = store.appendMessage(thread.id, { id: idFactory(), kind: 'ask', body, status: 'queued',
109
+ from: { agentId: from.agent?.agentId, name: from.agent ? label(from.agent) : CLIENT_LABELS[from.client] ?? from.client,
110
+ client: from.client, session: from.session },
111
+ to: { agentId: target.agentId, name: label(target) } });
112
+ const nested = history.filter((message) => message.kind === 'ask' && message.status === 'delivering').length;
113
+ if (!wait || nested >= NESTED_ASK_LIMIT) return queued(thread, ask, target, nested ? 'nested_limit' : 'not_waiting');
114
+ if (!taskContextToken || !from.projectId) return queued(thread, ask, target, 'task_context_required');
115
+ const attempts = await deliveryTargets(target, conversation, from.projectId, projectPath);
116
+ if (!attempts.length) return queued(thread, ask, target, 'no_client');
117
+ const prompt = wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: from.client });
118
+ const timeoutMs = Math.min(Math.max(timeoutSeconds, 30), 900) * 1000;
119
+ const unusable = new Set();
120
+ let failure = 'wake_failed';
121
+ for (const delivery of attempts) {
122
+ if (unusable.has(delivery.client)) continue;
123
+ store.updateMessage(ask.id, { status: 'delivering', toClient: delivery.client, toSession: delivery.sessionId });
124
+ let answer;
125
+ let grant;
126
+ try {
127
+ grant = await app.personal.issueAgentDelegation({ personal_space_id: spaceId(),
128
+ task_context_token: taskContextToken, source_application: from.client, agent_id: target.agentId,
129
+ target_application: delivery.client,
130
+ // Claude resumes into a fork with a new ID. Only Codex resumes in place.
131
+ target_session_id: delivery.client === 'codex' ? delivery.sessionId : null,
132
+ lifetime_seconds: timeoutMs / 1000 });
133
+ answer = await wake({ client: delivery.client, sessionId: delivery.sessionId, cwd: delivery.cwd,
134
+ timeoutMs, prompt, delegationToken: grant.token });
135
+ const verified = answer.sessionId && await app.personal.verifyAgentDelegation({
136
+ personal_space_id: spaceId(), token: grant.token, session_id: answer.sessionId });
137
+ if (!verified?.redeemed) throw Object.assign(new Error('The receiving session did not restore the Agent'),
138
+ { code: 'delegation_not_redeemed' });
139
+ } catch (error) {
140
+ failure = error.code ?? 'wake_failed';
141
+ // A missing or logged-out client cannot answer from any session. A conversation
142
+ // open in its app, or a sub-agent thread, still allows a new session.
143
+ if (['client_login_required', 'client_unavailable'].includes(failure)) unusable.add(delivery.client);
144
+ // A timeout already used the caller's wait.
145
+ if (failure === 'wake_timeout') break;
146
+ continue;
147
+ } finally {
148
+ // Even failed or timed-out attempts lose their authority. Expiry remains
149
+ // the fail-closed backstop if the Provider is temporarily unreachable.
150
+ if (grant?.token) await app.personal.revokeAgentDelegation({
151
+ personal_space_id: spaceId(), token: grant.token }).catch(() => {});
152
+ }
153
+ const via = `${delivery.client}:${delivery.sessionId ? 'resume' : 'new'}`;
154
+ store.updateMessage(ask.id, { status: 'answered', via });
155
+ const reply = store.appendMessage(thread.id, { id: idFactory(), kind: 'reply', body: answer.body, status: 'sent',
156
+ inReplyTo: ask.id, via, from: { agentId: target.agentId, name: label(target), client: delivery.client,
157
+ session: answer.sessionId }, to: { agentId: from.agent?.agentId, name: ask.from_name, client: from.client, session: from.session } });
158
+ return { status: 'answered', threadId: thread.id, messageId: ask.id, recipient: { agentId: target.agentId, name: label(target) },
159
+ via, reply: reply.body };
160
+ }
161
+ store.updateMessage(ask.id, { status: 'queued', error: failure });
162
+ return queued(thread, ask, target, failure);
163
+ }
164
+
165
+ async function readMessages({ taskContextToken, sourceApplication }) {
166
+ const me = await sender({ taskContextToken, sourceApplication });
167
+ if (!me.agent) return { messages: [] };
168
+ return { messages: store.pendingFor(spaceId(), me.agent.agentId).map(inboxItem),
169
+ guidance: 'Answer each with reply_agent_message.' };
170
+ }
171
+
172
+ async function replyMessage({ taskContextToken, sourceApplication, messageId, body }) {
173
+ const me = await sender({ taskContextToken, sourceApplication });
174
+ const ask = store.getMessage(messageId);
175
+ if (!ask || ask.kind !== 'ask' || !me.agent || ask.to_agent !== me.agent.agentId) throw validation('No such message for this Agent');
176
+ if (ask.status === 'answered') throw validation('This message was already answered');
177
+ store.updateMessage(ask.id, { status: 'answered', via: `${me.client}:inbox` });
178
+ store.appendMessage(ask.thread_id, { id: idFactory(), kind: 'reply', body, status: 'sent', inReplyTo: ask.id,
179
+ via: `${me.client}:inbox`, from: { agentId: me.agent.agentId, name: label(me.agent), client: me.client, session: me.session },
180
+ to: { agentId: ask.from_agent, name: ask.from_name, client: ask.from_client, session: ask.from_session } });
181
+ return { status: 'sent', threadId: ask.thread_id };
182
+ }
183
+
184
+ function threads({ limit = 50 } = {}) {
185
+ return { threads: store.listThreads(spaceId(), { limit }).map((thread) => {
186
+ const messages = store.messages(thread.id);
187
+ const people = new Map();
188
+ for (const message of messages) {
189
+ for (const [agentId, name, client] of [[message.from_agent, message.from_name, message.from_client], [message.to_agent, message.to_name, message.to_client]]) {
190
+ const key = agentId ?? name;
191
+ if (key && !people.has(key)) people.set(key, { agentId, name, client });
192
+ }
193
+ }
194
+ const last = messages.at(-1);
195
+ return { id: thread.id, subject: thread.subject, projectId: thread.project_id, updatedAt: thread.updated_at,
196
+ messageCount: messages.length, participants: [...people.values()],
197
+ waiting: messages.some((message) => message.kind === 'ask' && message.status !== 'answered'),
198
+ last: last ? { from: last.from_name, body: last.body.slice(0, 160) } : null };
199
+ }) };
200
+ }
201
+
202
+ function thread({ threadId }) {
203
+ const record = store.getThread(threadId);
204
+ if (!record || record.space_id !== spaceId()) throw validation('Thread not found');
205
+ return { id: record.id, subject: record.subject, projectId: record.project_id, createdAt: record.created_at,
206
+ messages: store.messages(record.id).map((message) => ({ id: message.id, kind: message.kind, status: message.status,
207
+ body: message.body, inReplyTo: message.in_reply_to, via: message.via, error: message.error, createdAt: message.created_at,
208
+ from: { agentId: message.from_agent, name: message.from_name, client: message.from_client },
209
+ to: { agentId: message.to_agent, name: message.to_name, client: message.to_client } })) };
210
+ }
211
+
212
+ function taskEntryDelegation({ sessionId }) {
213
+ // The public thread header is presentation only. The Provider validates this
214
+ // process-local capability, including project, recipient, client and session.
215
+ return env.FULI_ROUNDTABLE_DELEGATION
216
+ ? { token: env.FULI_ROUNDTABLE_DELEGATION, sessionId: sessionId ?? null } : null;
217
+ }
218
+
219
+ return { findAgents, messageAgent, readMessages, replyMessage, threads, thread, taskEntryDelegation,
220
+ pending: (agentId) => store.pendingFor(spaceId(), agentId).map(inboxItem),
221
+ close: () => opened?.close() };
222
+ }
223
+
224
+ function queued(thread, ask, target, reason) {
225
+ return { status: 'queued', threadId: thread.id, messageId: ask.id, reason,
226
+ recipient: { agentId: target.agentId, name: label(target) },
227
+ guidance: `${label(target)} will see this message when it next starts a task and answer with reply_agent_message.` };
228
+ }
229
+
230
+ function inboxItem(message) {
231
+ return { messageId: message.id, threadId: message.thread_id, from: message.from_name,
232
+ fromClient: message.from_client, body: message.body, createdAt: message.created_at };
233
+ }
234
+
235
+ function wakePrompt({ thread, target, senderName, senderClient, ask }) {
236
+ return `@{${encodeURIComponent(target.agentId)}} [FULI 圆桌 · ${thread.id}]\n`
237
+ + `${senderName}(${CLIENT_LABELS[senderClient] ?? senderClient})发来消息:\n\n${ask.body}\n\n`
238
+ + `请直接回答对方,你的最终回复会原样转达。需要再问其他 Agent 时,调用 message_agent 并带上 threadId="${thread.id}"。`;
239
+ }
240
+
241
+ function label(agent) {
242
+ return agent.profile.displayName || agent.profile.name || agent.agentId;
243
+ }
244
+
245
+ function validation(message) {
246
+ return new ApplicationError(ApplicationErrorCode.VALIDATION, message);
247
+ }
@@ -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,100 @@
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
+ delegationToken = null, 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
+ if (delegationToken) childEnv.FULI_ROUNDTABLE_DELEGATION = delegationToken;
41
+ const result = await run(command, wakeArguments(client, { sessionId, cwd }), { cwd, env: childEnv, input: prompt, timeoutMs });
42
+ return parseFinalAnswer(client, result);
43
+ }
44
+
45
+ export function parseFinalAnswer(client, { code, stdout }) {
46
+ const adapter = adapterFor(client);
47
+ const state = adapter.initialState();
48
+ let sessionId = null;
49
+ for (const line of stdout.split(/\r?\n/)) {
50
+ let event;
51
+ try { event = JSON.parse(line); } catch { continue; }
52
+ sessionId ??= event.thread_id ?? event.session_id ?? null;
53
+ adapter.readEvent(event, state);
54
+ if (event.error === 'authentication_failed') {
55
+ throw Object.assign(new Error(`${client} is not logged in on this machine`), { code: 'client_login_required' });
56
+ }
57
+ }
58
+ if (code !== 0 || state.failed || !state.completed || !state.body.trim()) {
59
+ throw Object.assign(new Error(`${client} did not return an answer`), { code: 'wake_failed' });
60
+ }
61
+ return { body: state.body.trim(), sessionId };
62
+ }
63
+
64
+ // Owns exactly one child process tree and never touches unrelated clients.
65
+ export function runProcess(command, args, { cwd, env, input, timeoutMs, maxOutputBytes = 4_000_000 }) {
66
+ return new Promise((resolve, reject) => {
67
+ const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true,
68
+ detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'ignore'] });
69
+ let output = '', bytes = 0, settled = false;
70
+ const kill = () => {
71
+ if (!child.pid) return;
72
+ if (process.platform === 'win32') spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).unref();
73
+ else { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already exited */ } }
74
+ };
75
+ const finish = (error, code) => {
76
+ if (settled) return;
77
+ settled = true;
78
+ clearTimeout(timer);
79
+ if (error) { kill(); reject(error); } else resolve({ code, stdout: output });
80
+ };
81
+ const timer = setTimeout(() => finish(Object.assign(new Error('The woken Agent did not answer in time'), { code: 'wake_timeout' })), timeoutMs);
82
+ child.stdout.on('data', (chunk) => {
83
+ bytes += chunk.length;
84
+ if (bytes > maxOutputBytes) finish(Object.assign(new Error('The woken Agent produced too much output'), { code: 'wake_failed' }));
85
+ else output += chunk.toString('utf8');
86
+ });
87
+ child.on('error', () => finish(Object.assign(new Error('The client could not be started'), { code: 'client_unavailable' })));
88
+ child.on('close', (code) => finish(null, code));
89
+ child.stdin.on('error', () => {});
90
+ child.stdin.end(input);
91
+ });
92
+ }
93
+
94
+ function whichCommand(name, platform) {
95
+ const result = spawnSync(platform === 'win32' ? 'where.exe' : 'which', [name], { encoding: 'utf8', windowsHide: true });
96
+ if (result.status !== 0) return null;
97
+ // On Windows only real executables can be spawned without a shell.
98
+ return result.stdout.split(/\r?\n/).map((line) => line.trim())
99
+ .find((line) => line && (platform !== 'win32' || /\.exe$/i.test(line))) ?? null;
100
+ }
@@ -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
  }
@@ -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
+ });
@@ -0,0 +1,47 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { findFile, firstField, listDirectory } from '../session-log.js';
6
+
7
+ // Wakes Codex headlessly with a read-only sandbox. Resuming records the exchange in
8
+ // that conversation; Codex refuses while the conversation is open in the app.
9
+ export const codexWake = Object.freeze({
10
+ id: 'codex',
11
+
12
+ resolveCommand({ env, which, platform, fileExists = existsSync }) {
13
+ if (env.FULI_CODEX_BIN) return env.FULI_CODEX_BIN;
14
+ const found = which('codex', platform);
15
+ if (found) return found;
16
+ if (platform === 'win32') return newestBundledCodex(join(env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'OpenAI', 'Codex', 'bin'));
17
+ const bundled = platform === 'darwin' ? '/Applications/Codex.app/Contents/Resources/codex' : null;
18
+ return bundled && fileExists(bundled) ? bundled : null;
19
+ },
20
+
21
+ args({ sessionId, cwd }) {
22
+ return sessionId
23
+ ? ['exec', 'resume', '--json', '--skip-git-repo-check', '-c', 'sandbox_mode="read-only"', sessionId, '-']
24
+ : ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', '-C', cwd, '-'];
25
+ },
26
+
27
+ // A session resumes only from the directory it started in.
28
+ sessionDirectory(sessionId, { home, env }) {
29
+ const file = findFile(join(env.CODEX_HOME || join(home, '.codex'), 'sessions'), `${sessionId}.jsonl`, 4);
30
+ return file ? firstField(file, (line) => line.type === 'session_meta' ? line.payload?.cwd : null) : null;
31
+ },
32
+
33
+ // `codex exec --json` streams items; the last agent message before turn.completed is the answer.
34
+ initialState: () => ({ body: '', completed: false, failed: false }),
35
+ readEvent(event, state) {
36
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') state.body = event.item.text ?? '';
37
+ if (event.type === 'turn.completed') state.completed = true;
38
+ if (event.type === 'turn.failed' || event.type === 'error') state.failed = true;
39
+ },
40
+ });
41
+
42
+ // The Codex app keeps each CLI version in its own folder; the newest is current.
43
+ function newestBundledCodex(root) {
44
+ const candidates = [join(root, 'codex.exe'), ...listDirectory(root).map((entry) => join(root, entry, 'codex.exe'))]
45
+ .filter((path) => existsSync(path));
46
+ return candidates.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] ?? null;
47
+ }