fuli-context 0.12.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +14 -6
  2. package/README.zh-CN.md +9 -4
  3. package/dist/web/assets/{AboutPage-BAD0iPVX.js → AboutPage-Dized8Uz.js} +1 -1
  4. package/dist/web/assets/{AgentConversations-1iGTd4sz.js → AgentConversations-Ccy41mEA.js} +1 -1
  5. package/dist/web/assets/{AgentHand-D_RafKOM.js → AgentHand-UzVhbKUC.js} +1 -1
  6. package/dist/web/assets/{AgentName-B5LKQ09q.js → AgentName-DJpvkub2.js} +1 -1
  7. package/dist/web/assets/AgentProfilePage-CFViQ8kj.js +1 -0
  8. package/dist/web/assets/AgentsDirectoryPage-CBFTg_IE.js +1 -0
  9. package/dist/web/assets/AgentsDirectoryPage-Cv-vxw2C.css +1 -0
  10. package/dist/web/assets/{ConnectionsPage-Tr2rRVOZ.js → ConnectionsPage-uXcabab_.js} +1 -1
  11. package/dist/web/assets/EmployeeRecruitDialog-D8Hvhc-M.js +1 -0
  12. package/dist/web/assets/EmployeeWorkbenchPage-TBl_HNj6.js +3 -0
  13. package/dist/web/assets/KnowledgeEditDialog-HmiqLC-Y.js +1 -0
  14. package/dist/web/assets/KnowledgeInspector-Cj2yK4Qh.js +1 -0
  15. package/dist/web/assets/KnowledgeOrganizerPage-DT3BROZm.js +1 -0
  16. package/dist/web/assets/KnowledgePage-C4J4VueL.js +1 -0
  17. package/dist/web/assets/KnowledgeWorkspace-DA_dqVRG.js +4 -0
  18. package/dist/web/assets/{NotFoundPage-fetSnp0N.js → NotFoundPage-x_Yd59mm.js} +1 -1
  19. package/dist/web/assets/{OverviewPage-BD4yY2Xz.js → OverviewPage-3eZjQ26v.js} +1 -1
  20. package/dist/web/assets/OverviewPage-8LBN8Shi.css +1 -0
  21. package/dist/web/assets/PersonalProfilePage-CDQk096L.js +1 -0
  22. package/dist/web/assets/PersonalProjectsPage-CJxp4Wyz.js +1 -0
  23. package/dist/web/assets/{ProjectAgentFirstTask-BcxqmKXQ.js → ProjectAgentFirstTask-BkRJ0zwO.js} +1 -1
  24. package/dist/web/assets/{ProjectAgentsPage-BWDJBESb.js → ProjectAgentsPage-BuNNqSEy.js} +8 -8
  25. package/dist/web/assets/{ProjectScopePicker-yodM7lF_.js → ProjectScopePicker-CiDxM2Yx.js} +1 -1
  26. package/dist/web/assets/PublicProjectsPage-ByOT559r.js +1 -0
  27. package/dist/web/assets/{ReviewPage-Cy0Rt4CO.js → ReviewPage-C0Vdr9uq.js} +1 -1
  28. package/dist/web/assets/RoundtablesPage-Drou-B81.css +1 -0
  29. package/dist/web/assets/RoundtablesPage-L3ShEWS3.js +1 -0
  30. package/dist/web/assets/SettingsPage-BE8pWbI6.css +1 -0
  31. package/dist/web/assets/SettingsPage-McYuinGz.js +1 -0
  32. package/dist/web/assets/{SharedJefaReportPage-C56kPciB.js → SharedJefaReportPage-Bg6lIk3E.js} +1 -1
  33. package/dist/web/assets/UiButton-x8hiHNRM.js +1 -0
  34. package/dist/web/assets/UiDialog-mjletK0y.js +1 -0
  35. package/dist/web/assets/{UiDisclosure-D0iMTplF.js → UiDisclosure-_IxeiexW.js} +1 -1
  36. package/dist/web/assets/{UiSegmented-DIJW6wv3.js → UiSegmented-WFfz9Evl.js} +1 -1
  37. package/dist/web/assets/{UiSelect-CMR0QqF6.js → UiSelect-K29jnV-1.js} +1 -1
  38. package/dist/web/assets/{WritingTastePage-QgNzssfF.js → WritingTastePage-DCULB-d2.js} +1 -1
  39. package/dist/web/assets/agent-pins-DS4mNsNt.js +1 -0
  40. package/dist/web/assets/agent-profile-Bh3MJGvb.css +1 -0
  41. package/dist/web/assets/{attention-store-B1DI8wmd.js → attention-store-CA5y8j1R.js} +1 -1
  42. package/dist/web/assets/catalog-D0VEfOAV.js +1 -0
  43. package/dist/web/assets/{client-CR-W5Wzp.js → client-DnwnUBQV.js} +1 -1
  44. package/dist/web/assets/{console-D7GHOMEC.js → console-Cw7TzOSC.js} +1 -1
  45. package/dist/web/assets/conversation-launcher-settings-Yc13viwr.js +1 -0
  46. package/dist/web/assets/{i18n-CVFhT_k7.js → i18n-oJFlt8hW.js} +3 -3
  47. package/dist/web/assets/{identity-CM6Dmy00.js → identity-DSGS4CZc.js} +1 -1
  48. package/dist/web/assets/index-BfHCcUL2.css +1 -0
  49. package/dist/web/assets/index-CxNER7Oo.js +5 -0
  50. package/dist/web/assets/{judgment-types-CJBEyJs1.js → judgment-types-DR931cZ7.js} +1 -1
  51. package/dist/web/assets/{model-WFhQZ3v_.js → model-BJ-UF4cs.js} +1 -1
  52. package/dist/web/assets/{personal-profile-graph-BwCh2M9G.js → personal-profile-graph-SOK0BR0w.js} +1 -1
  53. package/dist/web/assets/project-team-api-C_0qJk_B.js +1 -0
  54. package/dist/web/assets/{shared-report-CeIvrL9a.js → shared-report-CiX64ZiD.js} +1 -1
  55. package/dist/web/assets/useAgentRoster-B84mzZIg.js +1 -0
  56. package/dist/web/assets/{useApi-CROJJdhE-BYhbjaZB.js → useApi-CROJJdhE-7gt0QyQ5.js} +1 -1
  57. package/dist/web/assets/{vue-router-DaxrQwX3.js → vue-router-CCvPGaLD.js} +1 -1
  58. package/dist/web/index.html +15 -14
  59. package/dist/web/licenses/ant-design-icons.txt +22 -0
  60. package/docs/agent-conversations-and-collaboration.md +3 -3
  61. package/docs/agent-roundtable.md +33 -0
  62. package/docs/client-connections.md +108 -0
  63. package/docs/employee-agents.md +3 -3
  64. package/docs/project-agent-memory.md +6 -6
  65. package/graph-provider/fuli_graph/default_project_lead.py +1 -1
  66. package/graph-provider/fuli_graph/project_agent_delegations.py +70 -2
  67. package/graph-provider/fuli_graph/project_agent_memory_routes.py +18 -1
  68. package/graph-provider/fuli_graph/project_agent_remote_origins.py +154 -0
  69. package/graph-provider/fuli_graph/project_agent_task_models.py +1 -1
  70. package/graph-provider/fuli_graph/runtime.py +7 -0
  71. package/graph-provider/fuli_graph/store_project_agent_context.py +3 -2
  72. package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +1 -1
  73. package/graph-provider/fuli_graph/store_project_agent_task_reads.py +1 -1
  74. package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +5 -5
  75. package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +2 -2
  76. package/graph-provider/fuli_graph/store_project_agent_tasks.py +2 -2
  77. package/graph-provider/fuli_graph/store_project_agents.py +6 -6
  78. package/graph-provider/fuli_graph/system_agents.py +12 -2
  79. package/graph-provider/fuli_graph/system_hr_identity.py +2 -2
  80. package/graph-provider/pyproject.toml +2 -1
  81. package/npm-shrinkwrap.json +280 -5
  82. package/package.json +6 -2
  83. package/skills/operating-fuli/SKILL.md +3 -3
  84. package/src/agent-peer/coordinator-http.js +117 -0
  85. package/src/agent-peer/coordinator-service.js +258 -0
  86. package/src/agent-peer/coordinator-store.js +95 -0
  87. package/src/agent-peer/device-identity.js +70 -0
  88. package/src/agent-peer/http.js +47 -0
  89. package/src/agent-peer/node-store.js +127 -0
  90. package/src/agent-peer/origin-proof.js +56 -0
  91. package/src/agent-peer/peer-port.js +140 -0
  92. package/src/agent-peer/peer-runtime.js +298 -0
  93. package/src/agent-peer/peer-transport.js +78 -0
  94. package/src/agent-peer/protocol.js +161 -0
  95. package/src/agent-peer/runner.js +251 -0
  96. package/src/agent-roundtable/http.js +17 -3
  97. package/src/agent-roundtable/service.js +272 -43
  98. package/src/agent-roundtable/tools.js +15 -1
  99. package/src/agent-roundtable/wake.js +12 -3
  100. package/src/agent-tools/graph-definitions.js +14 -5
  101. package/src/agent-tools/graph-handlers.js +1 -0
  102. package/src/agent-tools/interface-catalog.js +10 -1
  103. package/src/agent-tools/project-agent-definitions.js +1 -1
  104. package/src/agents/identity-receipt.js +1 -1
  105. package/src/cli/command-registry.js +3 -0
  106. package/src/cli/connect-command.js +31 -0
  107. package/src/cli/mcp-command.js +101 -0
  108. package/src/cli/peer-command.js +100 -0
  109. package/src/cli/setup-agent-selection.js +2 -2
  110. package/src/cli/setup-command.js +12 -5
  111. package/src/cli.js +16 -0
  112. package/src/employees/catalog/bole.json +2 -2
  113. package/src/graphiti/federated-application.js +4 -1
  114. package/src/graphiti/project-agent-control-plane.js +7 -0
  115. package/src/graphiti/project-agent-task-entry.js +4 -4
  116. package/src/graphiti/project-agent-team-context.js +16 -18
  117. package/src/graphiti/project-team-view.js +82 -0
  118. package/src/graphiti/provider-client.js +9 -0
  119. package/src/graphiti/task-entry-preferences.js +1 -1
  120. package/src/http/api-router.js +10 -1
  121. package/src/http/graph-api-router.js +7 -0
  122. package/src/mcp/runtime.js +2 -1
  123. package/src/mcp/tool-annotations.js +4 -3
  124. package/src/mcp/tool-profiles.js +36 -0
  125. package/src/mcp-server.js +2 -100
  126. package/src/server.js +14 -2
  127. package/src/setup/agent-installation-status.js +5 -3
  128. package/src/setup/agents.js +23 -3
  129. package/src/setup/claude-desktop-config.js +60 -0
  130. package/src/setup/client-connection.js +25 -0
  131. package/src/setup/paths.js +3 -1
  132. package/src/setup/setup.js +1 -1
  133. package/src/setup/stable-package-root.js +29 -0
  134. package/src/system/system-service.js +5 -0
  135. package/dist/web/assets/AgentProfilePage-CioOdkBM.js +0 -1
  136. package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +0 -1
  137. package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +0 -1
  138. package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +0 -1
  139. package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +0 -3
  140. package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +0 -1
  141. package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +0 -1
  142. package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +0 -1
  143. package/dist/web/assets/KnowledgePage-DGOcBwr8.js +0 -1
  144. package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +0 -4
  145. package/dist/web/assets/OverviewPage-By-fUzSG.css +0 -1
  146. package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +0 -1
  147. package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +0 -1
  148. package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +0 -1
  149. package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +0 -1
  150. package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +0 -1
  151. package/dist/web/assets/SettingsPage-CCZqZtnH.js +0 -1
  152. package/dist/web/assets/SettingsPage-DYz5bTwa.css +0 -1
  153. package/dist/web/assets/UiDialog-CvDxg3B1.js +0 -1
  154. package/dist/web/assets/agent-pins-BcJ_qr8n.js +0 -1
  155. package/dist/web/assets/agent-profile-BZj3ezWe.css +0 -1
  156. package/dist/web/assets/catalog-DbXv0_bR.js +0 -1
  157. package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +0 -1
  158. package/dist/web/assets/index-CJ6Jmok9.css +0 -1
  159. package/dist/web/assets/index-CRa3GnYi.js +0 -5
  160. package/dist/web/assets/useAgentRoster-DCArHlxK.js +0 -1
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
 
3
3
  import { ApplicationError, ApplicationErrorCode } from '../app/application-error.js';
4
4
  import { WAKE_CLIENTS, resolveClientCommand, sessionWorkingDirectory, wakeAgent } from './wake.js';
@@ -6,6 +6,8 @@ import { WAKE_CLIENTS, resolveClientCommand, sessionWorkingDirectory, wakeAgent
6
6
  const CLIENT_LABELS = { claude_code: 'Claude Code', codex: 'Codex', cursor: 'Cursor', claude: 'Claude', other: 'Agent' };
7
7
  const THREAD_MESSAGE_LIMIT = 24;
8
8
  const NESTED_ASK_LIMIT = 3;
9
+ const REMOTE_PENDING = new Set(['sent', 'queued', 'claimed', 'running']);
10
+ const REMOTE_FINAL = new Set(['answered', 'failed', 'cancelled', 'expired', 'unknown']);
9
11
 
10
12
  // Agents talk to each other directly: an ask wakes the recipient in its own
11
13
  // client and returns its answer, or waits in its inbox for its next task.
@@ -14,6 +16,8 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
14
16
  const spaceId = () => app.config.personal.spaceId;
15
17
  // Opened on first use: most MCP processes never touch the roundtable.
16
18
  let opened = null;
19
+ const refreshes = new Map();
20
+ let refreshOffset = 0;
17
21
  const store = new Proxy({}, { get: (_target, property) => (opened ??= openStore())[property] });
18
22
 
19
23
  async function sender({ taskContextToken, sourceApplication }) {
@@ -60,18 +64,24 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
60
64
  agent.employeeNumber ? `FLA ${agent.employeeNumber}` : null, agent.profile.displayName,
61
65
  agent.profile.name, agent.profile.responsibility, ...(agent.profile.capabilities ?? [])]
62
66
  .some((value) => value?.toLowerCase().includes(needle)));
63
- return { agents: await Promise.all(matches.slice(0, 20).map(async (agent) => ({
67
+ const local = await Promise.all(matches.slice(0, 20).map(async (agent) => ({
64
68
  agentId: agent.agentId, employeeNumber: agent.employeeNumber, name: label(agent), responsibility: agent.profile.responsibility,
65
69
  clients: (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)),
66
70
  recentConversations: await conversations(agent, personalProjectId)
67
- }))),
68
- guidance: 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer.' };
71
+ })));
72
+ const remote = app.peer ? await app.peer.remoteAgents({ query: needle }).catch(() => null) : null;
73
+ return { agents: local,
74
+ ...(remote ? { remoteAgents: remote.agents, ...(remote.unavailable ? { remoteUnavailable: remote.unavailable } : {}) } : {}),
75
+ guidance: remote
76
+ ? 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer. remoteAgents run on other paired devices (LAN roundtable Beta): address them by their exact address, from an active project task. Only each shared project\'s lead can be asked. Use get_agent_message_status for a pending reply.'
77
+ : 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer.' };
69
78
  }
70
79
 
71
80
  // Ordered attempts: the chosen conversation (or the Agent's latest one), then a
72
81
  // 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));
82
+ async function deliveryTargets(agent, conversation, projectId, projectPath, { clients = null, freshCwd = null, strictCwd = false } = {}) {
83
+ const allowed = (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)
84
+ && (!clients || clients.includes(client)) && clientAvailable(client));
75
85
  const explicit = Boolean(conversation) && !['auto', 'new'].includes(conversation);
76
86
  let resumable = null;
77
87
  if (conversation !== 'new') {
@@ -89,49 +99,39 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
89
99
  const freshClients = resumable
90
100
  ? [resumable.client, ...(explicit ? [] : allowed.filter((client) => client !== resumable.client))]
91
101
  : WAKE_CLIENTS.filter((client) => allowed.includes(client));
102
+ // A strict delivery only starts new sessions in a folder known for this project.
103
+ const strictFolder = strictCwd ? freshCwd ?? resumable?.cwd ?? await projectFolder(agent, projectId) : null;
104
+ if (strictCwd && !strictFolder) return resumable ? [resumable] : [];
92
105
  const fresh = freshClients.map((client) => ({ client, sessionId: null,
93
- cwd: client === resumable?.client ? resumable.cwd : projectPath ?? resumable?.cwd ?? process.cwd() }));
106
+ cwd: client === resumable?.client ? resumable.cwd
107
+ : strictCwd ? strictFolder : freshCwd ?? projectPath ?? resumable?.cwd ?? process.cwd() }));
94
108
  return resumable ? [resumable, ...fresh] : fresh;
95
109
  }
96
110
 
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;
111
+ async function projectFolder(agent, projectId) {
112
+ for (const session of await conversations(agent, projectId)) {
113
+ if (session.projectId !== projectId) continue;
114
+ const cwd = locateCwd(session.client, session.sessionId);
115
+ if (cwd) return cwd;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ // Tries each target until one answers as the restored Agent. The grant is the
121
+ // only authority the woken session receives and is revoked after every attempt.
122
+ async function deliver({ ask, attempts, prompt, timeoutMs, issueGrant, signal, onSpawn, peerDepth }) {
119
123
  const unusable = new Set();
120
124
  let failure = 'wake_failed';
121
125
  for (const delivery of attempts) {
122
126
  if (unusable.has(delivery.client)) continue;
127
+ if (signal?.aborted) { failure = 'cancelled'; break; }
123
128
  store.updateMessage(ask.id, { status: 'delivering', toClient: delivery.client, toSession: delivery.sessionId });
124
129
  let answer;
125
130
  let grant;
126
131
  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 });
132
+ grant = await issueGrant(delivery, timeoutMs / 1000);
133
133
  answer = await wake({ client: delivery.client, sessionId: delivery.sessionId, cwd: delivery.cwd,
134
- timeoutMs, prompt, delegationToken: grant.token });
134
+ timeoutMs, prompt, delegationToken: grant.token, peerDepth, signal, onSpawn });
135
135
  const verified = answer.sessionId && await app.personal.verifyAgentDelegation({
136
136
  personal_space_id: spaceId(), token: grant.token, session_id: answer.sessionId });
137
137
  if (!verified?.redeemed) throw Object.assign(new Error('The receiving session did not restore the Agent'),
@@ -141,8 +141,8 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
141
141
  // A missing or logged-out client cannot answer from any session. A conversation
142
142
  // open in its app, or a sub-agent thread, still allows a new session.
143
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;
144
+ // A timeout already used the caller's wait; a cancellation ends the delivery.
145
+ if (failure === 'wake_timeout' || failure === 'cancelled') break;
146
146
  continue;
147
147
  } finally {
148
148
  // Even failed or timed-out attempts lose their authority. Expiry remains
@@ -150,16 +150,209 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
150
150
  if (grant?.token) await app.personal.revokeAgentDelegation({
151
151
  personal_space_id: spaceId(), token: grant.token }).catch(() => {});
152
152
  }
153
- const via = `${delivery.client}:${delivery.sessionId ? 'resume' : 'new'}`;
153
+ return { answer, delivery, via: `${delivery.client}:${delivery.sessionId ? 'resume' : 'new'}` };
154
+ }
155
+ throw Object.assign(new Error('No client answered'), { code: failure });
156
+ }
157
+
158
+ async function messageAgent({ taskContextToken = null, sourceApplication, to, body, threadId = null,
159
+ conversation = 'auto', wait = true, timeoutSeconds = 300, projectPath = null } = {}) {
160
+ if (app.peer?.isRemoteAddress(to)) {
161
+ return messageRemote({ taskContextToken, sourceApplication, to, body, threadId, conversation, wait, timeoutSeconds });
162
+ }
163
+ const from = await sender({ taskContextToken, sourceApplication });
164
+ const target = await recipient(to);
165
+ if (from.agent?.agentId === target.agentId && !threadId) throw validation('An Agent cannot open a thread with itself');
166
+ const { thread, history } = openThread(threadId, from.projectId, body);
167
+ const ask = store.appendMessage(thread.id, { id: idFactory(), kind: 'ask', body, status: 'queued',
168
+ from: { agentId: from.agent?.agentId, name: from.agent ? label(from.agent) : CLIENT_LABELS[from.client] ?? from.client,
169
+ client: from.client, session: from.session },
170
+ to: { agentId: target.agentId, name: label(target) } });
171
+ const nested = history.filter((message) => message.kind === 'ask' && message.status === 'delivering').length;
172
+ if (!wait || nested >= NESTED_ASK_LIMIT) return queued(thread, ask, target, nested ? 'nested_limit' : 'not_waiting');
173
+ if (!taskContextToken || !from.projectId) return queued(thread, ask, target, 'task_context_required');
174
+ const attempts = await deliveryTargets(target, conversation, from.projectId, projectPath);
175
+ if (!attempts.length) return queued(thread, ask, target, 'no_client');
176
+ const prompt = wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: from.client });
177
+ const timeoutMs = Math.min(Math.max(timeoutSeconds, 30), 900) * 1000;
178
+ try {
179
+ const { answer, delivery, via } = await deliver({ ask, attempts, prompt, timeoutMs,
180
+ issueGrant: (delivery, lifetime) => app.personal.issueAgentDelegation({ personal_space_id: spaceId(),
181
+ task_context_token: taskContextToken, source_application: from.client, agent_id: target.agentId,
182
+ target_application: delivery.client,
183
+ // Claude resumes into a fork with a new ID. Only Codex resumes in place.
184
+ target_session_id: delivery.client === 'codex' ? delivery.sessionId : null,
185
+ lifetime_seconds: lifetime }) });
154
186
  store.updateMessage(ask.id, { status: 'answered', via });
155
187
  const reply = store.appendMessage(thread.id, { id: idFactory(), kind: 'reply', body: answer.body, status: 'sent',
156
188
  inReplyTo: ask.id, via, from: { agentId: target.agentId, name: label(target), client: delivery.client,
157
189
  session: answer.sessionId }, to: { agentId: from.agent?.agentId, name: ask.from_name, client: from.client, session: from.session } });
158
190
  return { status: 'answered', threadId: thread.id, messageId: ask.id, recipient: { agentId: target.agentId, name: label(target) },
159
191
  via, reply: reply.body };
192
+ } catch (error) {
193
+ store.updateMessage(ask.id, { status: 'queued', error: error.code ?? 'wake_failed' });
194
+ return queued(thread, ask, target, error.code ?? 'wake_failed');
195
+ }
196
+ }
197
+
198
+ function openThread(threadId, projectId, body) {
199
+ let thread = threadId ? store.getThread(threadId) : null;
200
+ if (threadId && (!thread || thread.space_id !== spaceId())) throw validation('Thread not found');
201
+ const history = thread ? store.messages(thread.id) : [];
202
+ if (history.length >= THREAD_MESSAGE_LIMIT) throw validation('This thread reached its message limit; start a new one');
203
+ thread ??= store.createThread({ id: idFactory(), spaceId: spaceId(), projectId,
204
+ subject: body.trim().split(/\r?\n/)[0].slice(0, 80) });
205
+ return { thread, history };
206
+ }
207
+
208
+ // A remote ask is signed by this device for one verified local task. The task
209
+ // token itself never leaves this computer.
210
+ async function messageRemote({ taskContextToken, sourceApplication, to, body, threadId, conversation, wait, timeoutSeconds }) {
211
+ if (!['auto', 'new'].includes(conversation ?? 'auto')) throw validation('Remote Agents accept conversation "auto" or "new"');
212
+ const from = await sender({ taskContextToken, sourceApplication });
213
+ if (!from.agent || !from.projectId) throw validation('A remote message must come from an active project task of a FULI Agent; pass taskContextToken');
214
+ const target = await app.peer.resolveRemote(to);
215
+ const { thread } = openThread(threadId, from.projectId, body);
216
+ if (thread.project_id !== from.projectId) throw validation('This thread belongs to another project');
217
+ const ask = store.appendMessage(thread.id, { id: idFactory(), kind: 'ask', body, status: 'sent', via: 'peer',
218
+ from: { agentId: from.agent.agentId, name: label(from.agent), client: from.client, session: from.session },
219
+ to: { agentId: target.address, name: `${target.name} · ${target.deviceName}` } });
220
+ let state;
221
+ try {
222
+ state = await app.peer.send({ messageId: ask.id, threadId: thread.id, target, body,
223
+ conversation: conversation ?? 'auto', depth: peerDepth(env) + 1, taskContextToken, sourceApplication: from.client,
224
+ from: { agentId: from.agent.agentId, name: label(from.agent), client: from.client } });
225
+ } catch (error) {
226
+ store.updateMessage(ask.id, { status: 'failed', error: error.code ?? 'peer_send_failed' });
227
+ throw error;
228
+ }
229
+ if (wait) state = await app.peer.waitFor(ask.id, Math.min(Math.max(timeoutSeconds, 30), 900) * 1000);
230
+ return remoteView(applyRemote(ask.id, state));
231
+ }
232
+
233
+ function applyRemote(messageId, state) {
234
+ const ask = store.getMessage(messageId);
235
+ if (!ask || !state || !REMOTE_PENDING.has(ask.status)) return ask;
236
+ const status = state.status === 'queued' ? 'sent' : state.status;
237
+ if (status === 'answered' && state.reply) {
238
+ store.updateMessage(ask.id, { status: 'answered', via: state.via ?? 'peer' });
239
+ store.appendMessage(ask.thread_id, { id: idFactory(), kind: 'reply', body: state.reply, status: 'sent', inReplyTo: ask.id,
240
+ via: state.via ?? 'peer', from: { agentId: ask.to_agent, name: ask.to_name, client: state.via?.split(':')[0] ?? null },
241
+ to: { agentId: ask.from_agent, name: ask.from_name, client: ask.from_client, session: ask.from_session } });
242
+ } else if (status !== ask.status) {
243
+ store.updateMessage(ask.id, { status, error: state.error ?? null });
160
244
  }
161
- store.updateMessage(ask.id, { status: 'queued', error: failure });
162
- return queued(thread, ask, target, failure);
245
+ return store.getMessage(ask.id);
246
+ }
247
+
248
+ function remoteView(ask) {
249
+ const reply = ask.status === 'answered'
250
+ ? store.messages(ask.thread_id).find((message) => message.in_reply_to === ask.id) : null;
251
+ return { status: ask.status === 'sent' ? 'queued' : ask.status, threadId: ask.thread_id, messageId: ask.id,
252
+ recipient: { agentId: ask.to_agent, name: ask.to_name }, remote: true,
253
+ ...(reply ? { reply: reply.body, via: reply.via } : {}), ...(ask.error ? { reason: ask.error } : {}),
254
+ ...(REMOTE_FINAL.has(ask.status) ? {} : { guidance: 'The remote device has not answered yet. Check later with get_agent_message_status; it does not mean the work ran.' }),
255
+ ...(ask.status === 'unknown' ? { guidance: 'The remote device lost track of this attempt; it may or may not have run. It is not retried automatically.' } : {}) };
256
+ }
257
+
258
+ // Runs on the receiving device for one claimed, locally authorized remote ask.
259
+ async function answerRemote({ claim, share, prompt: promptOverride = null, issueGrant, signal, onSpawn, timeoutMs }) {
260
+ const { assertion, sender: device } = claim;
261
+ if (share.spaceId !== spaceId()) throw Object.assign(new Error('The share belongs to another space'), { code: 'share_unavailable' });
262
+ const target = await agentById(share.agentId);
263
+ if (!target) throw Object.assign(new Error('The shared Agent is no longer active'), { code: 'agent_unavailable' });
264
+ // Remote IDs are only meaningful together with their device and this share,
265
+ // so they never address an existing local row.
266
+ const scope = [device.nodeId, share.spaceId, share.projectId, share.bindingId, share.agentId];
267
+ const threadId = remoteRowId('thread', [...scope, assertion.threadId]);
268
+ const askId = remoteRowId('ask', [...scope, assertion.messageId]);
269
+ if (store.getMessage(askId)) {
270
+ throw Object.assign(new Error('This remote message was already delivered here'), { code: 'already_delivered' });
271
+ }
272
+ const thread = store.getThread(threadId) ?? store.createThread({ id: threadId, spaceId: share.spaceId,
273
+ projectId: share.projectId, subject: assertion.body.trim().split(/\r?\n/)[0].slice(0, 80) });
274
+ const ask = store.appendMessage(thread.id, { id: askId, kind: 'ask',
275
+ body: assertion.body, status: 'delivering', via: 'peer',
276
+ from: { agentId: `peer:${device.nodeId.slice(0, 16)}:${assertion.from.agentId}`, name: `${assertion.from.name} · ${device.name}`,
277
+ client: assertion.from.client ?? 'other' },
278
+ to: { agentId: target.agentId, name: label(target) } });
279
+ const attempts = await deliveryTargets(target, assertion.conversation, share.projectId, null,
280
+ { clients: share.clients, freshCwd: share.workingDirectory ?? null, strictCwd: true });
281
+ if (!attempts.length) {
282
+ const code = share.clients.some((client) => clientAvailable(client)) ? 'no_working_directory' : 'no_client';
283
+ store.updateMessage(ask.id, { status: 'failed', error: code });
284
+ throw Object.assign(new Error(code === 'no_working_directory'
285
+ ? 'This shared project has no known folder on this device; the owner sets one in Settings'
286
+ : 'No shared client can run on this device'), { code });
287
+ }
288
+ const prompt = promptOverride ?? wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: ask.from_client });
289
+ try {
290
+ const { answer, delivery, via } = await deliver({ ask, attempts, prompt, timeoutMs, issueGrant, signal, onSpawn,
291
+ peerDepth: assertion.depth });
292
+ store.updateMessage(ask.id, { status: 'answered', via });
293
+ store.appendMessage(thread.id, { id: idFactory(), kind: 'reply', body: answer.body, status: 'sent', inReplyTo: ask.id, via,
294
+ from: { agentId: target.agentId, name: label(target), client: delivery.client, session: answer.sessionId },
295
+ to: { agentId: ask.from_agent, name: ask.from_name, client: ask.from_client } });
296
+ return { body: answer.body, via, sessionId: answer.sessionId };
297
+ } catch (error) {
298
+ store.updateMessage(ask.id, { status: error.code === 'cancelled' ? 'cancelled' : 'failed', error: error.code ?? 'wake_failed' });
299
+ throw error;
300
+ }
301
+ }
302
+
303
+ async function messageStatus({ taskContextToken, sourceApplication, messageId }) {
304
+ const me = await sender({ taskContextToken, sourceApplication });
305
+ const ask = taskScopedAsk(me, messageId);
306
+ if (![ask.from_agent, ask.to_agent].includes(me.agent.agentId)) throw validation('No such message for this Agent');
307
+ return statusOf(ask);
308
+ }
309
+
310
+ async function statusOf(ask) {
311
+ if (REMOTE_PENDING.has(ask.status) && app.peer?.hasSent(ask.id)) {
312
+ const state = await app.peer.status(ask.id).catch(() => null);
313
+ return remoteView(applyRemote(ask.id, state) ?? ask);
314
+ }
315
+ if (ask.via === 'peer' || app.peer?.hasSent(ask.id)) return remoteView(ask);
316
+ const reply = store.messages(ask.thread_id).find((message) => message.in_reply_to === ask.id);
317
+ return { status: ask.status, threadId: ask.thread_id, messageId: ask.id,
318
+ recipient: { agentId: ask.to_agent, name: ask.to_name }, ...(reply ? { reply: reply.body, via: reply.via } : {}),
319
+ ...(ask.error ? { reason: ask.error } : {}) };
320
+ }
321
+
322
+ async function cancelMessage({ taskContextToken, sourceApplication, messageId }) {
323
+ const me = await sender({ taskContextToken, sourceApplication });
324
+ const ask = taskScopedAsk(me, messageId);
325
+ if (ask.from_agent !== me.agent.agentId) throw validation('No such message sent by this Agent');
326
+ return cancelAsk(ask);
327
+ }
328
+
329
+ // The same Agent ID can work in several projects; a task only reaches its own project's messages.
330
+ function taskScopedAsk(me, messageId) {
331
+ const ask = store.getMessage(messageId);
332
+ const thread = ask && store.getThread(ask.thread_id);
333
+ if (!ask || ask.kind !== 'ask' || !me.agent || !me.projectId || thread?.space_id !== spaceId()
334
+ || thread.project_id !== me.projectId) {
335
+ throw validation('No such message for this Agent in the current project');
336
+ }
337
+ return ask;
338
+ }
339
+
340
+ async function cancelAsk(ask) {
341
+ if (app.peer?.hasSent(ask.id)) {
342
+ if (!REMOTE_PENDING.has(ask.status)) return remoteView(ask);
343
+ return remoteView(applyRemote(ask.id, await app.peer.cancel(ask.id)));
344
+ }
345
+ if (ask.status === 'queued') store.updateMessage(ask.id, { status: 'cancelled', error: 'cancelled' });
346
+ else if (ask.status === 'delivering') throw validation('This message is being answered in another process; it ends at its timeout');
347
+ return statusOf(store.getMessage(ask.id));
348
+ }
349
+
350
+ // The local owner can inspect and cancel any message from the console.
351
+ function ownedAsk(messageId) {
352
+ const ask = store.getMessage(messageId);
353
+ const thread = ask && store.getThread(ask.thread_id);
354
+ if (!ask || ask.kind !== 'ask' || thread?.space_id !== spaceId()) throw validation('Message not found');
355
+ return ask;
163
356
  }
164
357
 
165
358
  async function readMessages({ taskContextToken, sourceApplication }) {
@@ -174,6 +367,7 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
174
367
  const ask = store.getMessage(messageId);
175
368
  if (!ask || ask.kind !== 'ask' || !me.agent || ask.to_agent !== me.agent.agentId) throw validation('No such message for this Agent');
176
369
  if (ask.status === 'answered') throw validation('This message was already answered');
370
+ if (ask.from_agent?.startsWith('peer:')) throw validation('A remote device message is answered by its delivery, not from the inbox');
177
371
  store.updateMessage(ask.id, { status: 'answered', via: `${me.client}:inbox` });
178
372
  store.appendMessage(ask.thread_id, { id: idFactory(), kind: 'reply', body, status: 'sent', inReplyTo: ask.id,
179
373
  via: `${me.client}:inbox`, from: { agentId: me.agent.agentId, name: label(me.agent), client: me.client, session: me.session },
@@ -181,6 +375,29 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
181
375
  return { status: 'sent', threadId: ask.thread_id };
182
376
  }
183
377
 
378
+ function refreshRemote({ threadId = null } = {}) {
379
+ if (!app.peer) return Promise.resolve();
380
+ const key = threadId ?? 'directory';
381
+ if (refreshes.has(key)) return refreshes.get(key);
382
+ const records = threadId ? [store.getThread(threadId)] : store.listThreads(spaceId(), { limit: 200 });
383
+ const pending = records.filter((record) => record?.space_id === spaceId()).flatMap((record) => store.messages(record.id))
384
+ .filter((message) => message.kind === 'ask' && REMOTE_PENDING.has(message.status) && app.peer.hasSent(message.id));
385
+ const start = pending.length && !threadId ? refreshOffset % pending.length : 0;
386
+ const batch = [...pending.slice(start), ...pending.slice(0, start)].slice(0, 20);
387
+ if (!threadId) refreshOffset += batch.length;
388
+ // A shared deadline bounds the whole console refresh even with many offline asks.
389
+ const signal = AbortSignal.timeout(2_000);
390
+ const work = Promise.all(Array.from({ length: Math.min(4, batch.length) }, async () => {
391
+ while (batch.length && !signal.aborted) {
392
+ const message = batch.shift();
393
+ try { applyRemote(message.id, await app.peer.status(message.id, { signal, timeoutMs: 2_000 })); }
394
+ catch { /* Keep the last known result when the other computer is unavailable. */ }
395
+ }
396
+ })).finally(() => refreshes.delete(key));
397
+ refreshes.set(key, work);
398
+ return work;
399
+ }
400
+
184
401
  function threads({ limit = 50 } = {}) {
185
402
  return { threads: store.listThreads(spaceId(), { limit }).map((thread) => {
186
403
  const messages = store.messages(thread.id);
@@ -194,7 +411,7 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
194
411
  const last = messages.at(-1);
195
412
  return { id: thread.id, subject: thread.subject, projectId: thread.project_id, updatedAt: thread.updated_at,
196
413
  messageCount: messages.length, participants: [...people.values()],
197
- waiting: messages.some((message) => message.kind === 'ask' && message.status !== 'answered'),
414
+ waiting: messages.some((message) => message.kind === 'ask' && !['answered', 'cancelled', 'failed', 'expired', 'unknown'].includes(message.status)),
198
415
  last: last ? { from: last.from_name, body: last.body.slice(0, 160) } : null };
199
416
  }) };
200
417
  }
@@ -216,11 +433,23 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
216
433
  ? { token: env.FULI_ROUNDTABLE_DELEGATION, sessionId: sessionId ?? null } : null;
217
434
  }
218
435
 
219
- return { findAgents, messageAgent, readMessages, replyMessage, threads, thread, taskEntryDelegation,
436
+ return { findAgents, messageAgent, readMessages, replyMessage, threads, thread, refreshRemote, taskEntryDelegation,
437
+ messageStatus, cancelMessage, answerRemote,
438
+ ownerMessageStatus: ({ messageId }) => statusOf(ownedAsk(messageId)),
439
+ ownerCancelMessage: ({ messageId }) => cancelAsk(ownedAsk(messageId)),
220
440
  pending: (agentId) => store.pendingFor(spaceId(), agentId).map(inboxItem),
221
441
  close: () => opened?.close() };
222
442
  }
223
443
 
444
+ function remoteRowId(kind, parts) {
445
+ return `peer-${kind}-${createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 40)}`;
446
+ }
447
+
448
+ function peerDepth(env) {
449
+ const depth = Number(env.FULI_ROUNDTABLE_PEER_DEPTH);
450
+ return Number.isSafeInteger(depth) && depth > 0 ? depth : 0;
451
+ }
452
+
224
453
  function queued(thread, ask, target, reason) {
225
454
  return { status: 'queued', threadId: thread.id, messageId: ask.id, reason,
226
455
  recipient: { agentId: target.agentId, name: label(target) },
@@ -13,7 +13,7 @@ export const ROUNDTABLE_TOOL_DEFINITIONS = [
13
13
  {
14
14
  name: 'message_agent',
15
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.',
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. A peer:<device>:<binding> address from find_agents asks the shared project lead on another paired device (LAN roundtable Beta, project leads only). Every exchange is recorded for the user.',
17
17
  inputSchema: objectSchema({
18
18
  taskContextToken: text(160),
19
19
  to: text(160),
@@ -43,6 +43,18 @@ export const ROUNDTABLE_TOOL_DEFINITIONS = [
43
43
  title: 'ROUNDTABLE · Read a thread',
44
44
  description: 'Read every message of one Agent conversation thread.',
45
45
  inputSchema: objectSchema({ threadId: text(128) }, ['threadId'])
46
+ },
47
+ {
48
+ name: 'get_agent_message_status',
49
+ title: 'ROUNDTABLE · Check a message',
50
+ description: 'Check whether a message you sent or received in the current task\'s project was answered, including asks to a shared project lead on another paired device (LAN roundtable Beta). "unknown" means the receiving device lost track of the attempt; it may or may not have run and is not retried.',
51
+ inputSchema: objectSchema({ taskContextToken: text(160), messageId: text(128) }, ['taskContextToken', 'messageId'])
52
+ },
53
+ {
54
+ name: 'cancel_agent_message',
55
+ title: 'ROUNDTABLE · Cancel a message',
56
+ description: 'Cancel a message the current task\'s Agent sent in this project. A waiting message is withdrawn; one already running on another device is asked to stop.',
57
+ inputSchema: objectSchema({ taskContextToken: text(160), messageId: text(128) }, ['taskContextToken', 'messageId'])
46
58
  }
47
59
  ];
48
60
 
@@ -56,6 +68,8 @@ export function callRoundtableTool(roundtable, name, input) {
56
68
  case 'read_agent_messages': return roundtable.readMessages(input);
57
69
  case 'reply_agent_message': return roundtable.replyMessage(input);
58
70
  case 'read_agent_thread': return roundtable.thread(input);
71
+ case 'get_agent_message_status': return roundtable.messageStatus(input);
72
+ case 'cancel_agent_message': return roundtable.cancelMessage(input);
59
73
  default: throw new TypeError(`Unknown roundtable tool: ${name}`);
60
74
  }
61
75
  }
@@ -33,12 +33,14 @@ export function sessionWorkingDirectory(client, sessionId, { home = homedir(), e
33
33
  }
34
34
 
35
35
  export async function wakeAgent({ client, sessionId = null, cwd, prompt, timeoutMs = 300_000,
36
- delegationToken = null, env = process.env, run = runProcess } = {}) {
36
+ delegationToken = null, peerDepth = null, env = process.env, run = runProcess, signal, onSpawn } = {}) {
37
37
  const command = resolveClientCommand(client, { env });
38
38
  if (!command) throw Object.assign(new Error(`${client} is not installed on this machine`), { code: 'client_unavailable' });
39
39
  const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith('FULI_ROUNDTABLE_')));
40
40
  if (delegationToken) childEnv.FULI_ROUNDTABLE_DELEGATION = delegationToken;
41
- const result = await run(command, wakeArguments(client, { sessionId, cwd }), { cwd, env: childEnv, input: prompt, timeoutMs });
41
+ if (peerDepth) childEnv.FULI_ROUNDTABLE_PEER_DEPTH = String(peerDepth);
42
+ const result = await run(command, wakeArguments(client, { sessionId, cwd }),
43
+ { cwd, env: childEnv, input: prompt, timeoutMs, signal, onSpawn });
42
44
  return parseFinalAnswer(client, result);
43
45
  }
44
46
 
@@ -62,10 +64,17 @@ export function parseFinalAnswer(client, { code, stdout }) {
62
64
  }
63
65
 
64
66
  // 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 }) {
67
+ export function runProcess(command, args, { cwd, env, input, timeoutMs, maxOutputBytes = 4_000_000, signal, onSpawn }) {
66
68
  return new Promise((resolve, reject) => {
69
+ if (signal?.aborted) {
70
+ reject(Object.assign(new Error('The delivery was cancelled before it started'), { code: 'cancelled', started: false }));
71
+ return;
72
+ }
67
73
  const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true,
68
74
  detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'ignore'] });
75
+ child.once('spawn', () => onSpawn?.());
76
+ signal?.addEventListener('abort', () => finish(Object.assign(new Error('The delivery was cancelled'),
77
+ { code: 'cancelled', reason: String(signal.reason ?? 'cancelled') })), { once: true });
69
78
  let output = '', bytes = 0, settled = false;
70
79
  const kill = () => {
71
80
  if (!child.pid) return;
@@ -764,7 +764,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
764
764
  {
765
765
  name: 'coordinate_project_agent_task',
766
766
  title: 'WRITE · Prepare a host-executed Agent team',
767
- description: 'Resolve the exact local project, or create an isolated temporary task scope when no project matches, then let the Provider coordinator route one durable task and assemble an isolated context bundle for every selected lead/collaborator. Ambiguous matches require exact selection. Pass the current taskContextToken so temporary scope and a newly recruited lead bind to the owned lifecycle; otherwise provide a stable sourceSessionId and idempotencyKey for retries. Temporary projects retain audit history, never become implicit directory bindings, and default to audited task-only recruitment under the existing HR policy; explicit durable recruitment requests remain explicit. When recruiting, HR should set recruitmentProfile.character from the role responsibilities and preserve user expectations; configured traits must never be presented as proven growth. Fuli persists identity, staffing, and memory scope; the host must start real workers, report their concrete evidence, and finish their lifecycle. A routing recommendation never proves that a worker started.',
767
+ description: 'Resolve the exact local project, or create an isolated temporary task scope when no project matches, then let the Provider coordinator route one durable task and assemble an isolated context bundle for every selected lead/collaborator. Ambiguous matches require exact selection. Pass the current taskContextToken so temporary scope and a newly recruited lead bind to the owned lifecycle; otherwise provide a stable sourceSessionId and idempotencyKey for retries. Temporary projects retain audit history, never become implicit directory bindings, and default to audited task-only recruitment under the existing AR policy; explicit durable recruitment requests remain explicit. When recruiting, AR should set recruitmentProfile.character from the role responsibilities and preserve user expectations; configured traits must never be presented as proven growth. Fuli persists identity, staffing, and memory scope; the host must start real workers, report their concrete evidence, and finish their lifecycle. A routing recommendation never proves that a worker started.',
768
768
  inputSchema: projectAgentTaskCoordinateInput
769
769
  },
770
770
  {
@@ -792,7 +792,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
792
792
  {
793
793
  name: 'submit_project_agent_task',
794
794
  title: 'WRITE · Submit a routed Agent task',
795
- description: 'Submit one durable task to the FULI control plane. Every new task requires a passed quality gate for its exact artifact revision before completion; use record_agent_verification with actual run evidence. Existing matching durable Agents are preferred; no anonymous temporary Agent is created. The Agent locked executor policy outranks task, assignment, project, and space rules; explicit user priority is never lowered for token savings. The space coordinator, complexity, model strategy, routing decision, boundaries, actual executor/model/rule/fallback audit fields, and any HR disclosure are persisted by the Provider. HR should fill recruitmentProfile.character from role responsibilities, preserve user expectations, and distinguish configured traits from demonstrated growth; never invent past achievements.',
795
+ description: 'Submit one durable task to the FULI control plane. Every new task requires a passed quality gate for its exact artifact revision before completion; use record_agent_verification with actual run evidence. Existing matching durable Agents are preferred; no anonymous temporary Agent is created. The Agent locked executor policy outranks task, assignment, project, and space rules; explicit user priority is never lowered for token savings. The space coordinator, complexity, model strategy, routing decision, boundaries, actual executor/model/rule/fallback audit fields, and any AR disclosure are persisted by the Provider. AR should fill recruitmentProfile.character from role responsibilities, preserve user expectations, and distinguish configured traits from demonstrated growth; never invent past achievements.',
796
796
  inputSchema: projectAgentTaskSubmitInput
797
797
  },
798
798
  {
@@ -833,10 +833,19 @@ export const GRAPH_TOOL_DEFINITIONS = [
833
833
  personalProjectId: id
834
834
  }, ['personalSpaceId', 'personalProjectId'])
835
835
  },
836
+ {
837
+ name: 'get_project_team',
838
+ title: 'READ · Get project team',
839
+ description: 'Read the exact project people view: the one accountable lead, explicit team members reporting to that lead, other Agents with an active assignment as collaborators, and assigned peer roles such as AR or project managers. Ended and archived assignments are excluded; status no_lead or lead_unavailable is reported truthfully. Never includes private memory and never resolves a parent or child project.',
840
+ inputSchema: objectSchema({
841
+ personalSpaceId: id,
842
+ personalProjectId: id
843
+ }, ['personalSpaceId', 'personalProjectId'])
844
+ },
836
845
  {
837
846
  name: 'update_project_agent_coordination_policy',
838
847
  title: 'WRITE · Set project Agent continuity policy',
839
- description: 'Persist project collaboration switches and an optional stable team lead with members. HR and employee project managers remain peer roles and cannot be team members. Omitted team fields preserve the current team. Read policy first and send expectedUpdatedAt to prevent overwriting concurrent edits. This changes policy only and never starts a worker.',
848
+ description: 'Persist project collaboration switches and an optional stable team lead with members. AR and employee project managers remain peer roles and cannot be team members. Omitted team fields preserve the current team. Read policy first and send expectedUpdatedAt to prevent overwriting concurrent edits. This changes policy only and never starts a worker.',
840
849
  inputSchema: objectSchema({
841
850
  personalSpaceId: id,
842
851
  personalProjectId: id,
@@ -869,7 +878,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
869
878
  {
870
879
  name: 'list_project_agent_recruitments',
871
880
  title: 'READ · List recruitment records',
872
- description: 'List auditable HR recruitment records with trigger client/session, role, capabilities, position kind, reason, status, and created/recruited Agent.',
881
+ description: 'List auditable AR recruitment records with trigger client/session, role, capabilities, position kind, reason, status, and created/recruited Agent.',
873
882
  inputSchema: objectSchema({
874
883
  personalSpaceId: id,
875
884
  personalProjectId: nullableStringSchema(),
@@ -880,7 +889,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
880
889
  {
881
890
  name: 'decide_project_agent_recruitment',
882
891
  title: 'WRITE · Approve or cancel recruitment',
883
- description: 'Approve or cancel one pending HR recruitment event with an expected revision. Approval creates only the declared durable or bounded temporary role through the Provider.',
892
+ description: 'Approve or cancel one pending AR recruitment event with an expected revision. Approval creates only the declared durable or bounded temporary role through the Provider.',
884
893
  inputSchema: objectSchema({
885
894
  personalSpaceId: id,
886
895
  personalProjectId: id,
@@ -154,6 +154,7 @@ const HANDLERS = Object.freeze({
154
154
  view_project_agent_activity: (app, input) => app.viewProjectAgentActivity(input),
155
155
  get_project_agent_coordination_policy: (app, input) =>
156
156
  app.getProjectAgentCoordinationPolicy(input),
157
+ get_project_team: (app, input) => app.getProjectTeam(input),
157
158
  update_project_agent_coordination_policy: (app, input) =>
158
159
  app.updateProjectAgentCoordinationPolicy(input),
159
160
  get_project_agent_recruitment_policy: (app, input) =>
@@ -1,5 +1,13 @@
1
1
  const UI_MUTATION_PARITY = Object.freeze([
2
2
  agent('PUT /api/agent-pins', 'pin_agent'),
3
+ agent('POST /api/roundtable/messages/:messageId/cancel', 'cancel_agent_message'),
4
+ local('POST /api/peer/coordinator', 'Opening a LAN listener on this device is a local device decision of its owner.'),
5
+ local('POST /api/peer/invitations', 'Pairing invitations admit another device and are created by the local owner.'),
6
+ local('POST /api/peer/invitations/preview', 'The local owner inspects a private device invitation before deciding to pair.'),
7
+ local('POST /api/peer/join', 'Pairing this device with a coordinator is a local owner decision.'),
8
+ local('PUT /api/peer/shares', 'Which project leads, clients and folders other devices may use is chosen by the local owner.'),
9
+ local('POST /api/peer/devices/:nodeId/revoke', 'Revoking a paired device is a local owner decision.'),
10
+ local('POST /api/peer/disable', 'Leaving the LAN roundtable is a local owner decision.'),
3
11
  local('PUT /api/judgment/policy', 'Autonomy grants delegated authority and requires the local user.'),
4
12
  local('POST /api/judgment/feedback', 'Votes and reasons are human feedback, never model-generated approval.'),
5
13
  agent('POST /api/judgment/review', 'review_with_tonborg'),
@@ -88,7 +96,8 @@ export function agentInterfaceCatalog() {
88
96
  { domain: 'human-attention', toolNames: ['list_agent_attention', 'request_agent_attention', 'cancel_agent_attention'], guidance: 'Raise a hand only for an explicit human action. Read resolved responses before continuing. Responses do not grant permissions or accept tasks.' },
89
97
  { domain: 'projects', toolNames: ['list_knowledge_spaces', 'list_personal_projects', 'list_project_releases', 'list_project_relations'] },
90
98
  { domain: 'knowledge', toolNames: ['search_knowledge_graph', 'get_knowledge_graph', 'get_user_taste_skill', 'list_preference_conflicts'] },
91
- { domain: 'people-and-tasks', toolNames: ['list_project_agents', 'list_project_agent_assignments', 'list_project_agent_tasks', 'view_project_agent_task', 'view_project_agent_activity', 'list_project_agent_recruitments'] },
99
+ { domain: 'people-and-tasks', toolNames: ['get_project_team', 'list_project_agents', 'list_project_agent_assignments', 'list_project_agent_tasks', 'view_project_agent_task', 'view_project_agent_activity', 'list_project_agent_recruitments'] },
100
+ { domain: 'roundtable', toolNames: ['find_agents', 'read_agent_messages', 'read_agent_thread', 'get_agent_message_status'], readRoutes: ['GET /api/roundtable/threads', 'GET /api/roundtable/threads/:threadId', 'GET /api/roundtable/messages/:messageId'], guidance: 'message_agent and reply_agent_message write recorded thread messages as the current task Agent. A queued message is not a reply, and a reply is not evidence that work ran. Remote asks reach only a shared project lead on a paired device; unknown means it may or may not have run. Pairing and sharing devices stay with the local owner. When task entry returns requested_agent_id, this session is still the selected Agent; follow requested_agent_guidance instead of acting as the requested Agent.' },
92
101
  { domain: 'employees-and-boards', toolNames: ['list_employee_templates', 'list_employee_tools', 'call_employee_tool'], guidance: 'Discover the employee read_board and other read tools before invoking them with exact project scope.' },
93
102
  { domain: 'external-knowledge', toolNames: ['list_external_knowledge_connectors', 'discover_external_knowledge_sources', 'list_external_knowledge_bindings', 'retrieve_external_knowledge_binding', 'get_external_knowledge_conflict_policy'], readRoutes: ['POST /api/external-knowledge/discover', 'POST /api/external-knowledge/bindings/:bindingId/retrieve'] },
94
103
  { domain: 'executors', toolNames: ['list_executors', 'get_executor', 'list_executor_routing_rules', 'list_project_agent_routing_learning'] },
@@ -61,7 +61,7 @@ export const projectAgentProfile = objectSchema({
61
61
  taste: { type: 'string', maxLength: 2048, default: '' },
62
62
  personality: { type: 'string', maxLength: 2048, default: '' }
63
63
  }),
64
- description: 'HR-configured judgment, taste and personality based on role responsibilities. These are intended working traits, not evidence of observed growth or performance.'
64
+ description: 'AR-configured judgment, taste and personality based on role responsibilities. These are intended working traits, not evidence of observed growth or performance.'
65
65
  },
66
66
  expectations: {
67
67
  type: 'string', maxLength: 4096, default: '',
@@ -53,7 +53,7 @@ function unassignedReceipt(status, reason) {
53
53
  agent_selection_conflict: '分配待确认(FLA 选择冲突)',
54
54
  manual_selection: '分配待确认(需要选择负责人)'
55
55
  };
56
- return `FULI Agent:${labels[status] ?? '分配未完成(HR 尚未返回负责人)'}`;
56
+ return `FULI Agent:${labels[status] ?? '分配未完成(AR 尚未返回负责人)'}`;
57
57
  }
58
58
 
59
59
  export function employeeCollaborationReceipt(application, context, { tool, permission, sourceApplication }) {
@@ -19,8 +19,11 @@ Local service:
19
19
  open [--data-dir DIR]
20
20
 
21
21
  Install and Agent connection:
22
+ connect [--format standard|vscode|server] [--data-dir DIR | --runtime-config FILE] (print generic MCP configuration)
23
+ mcp [--runtime-config FILE] [--source-application codex|claude|claude_code|cursor|kiro|other] (stdio server)
22
24
  setup [--yes] [--codex-only] [--data-dir DIR] [--personal-space NAME] [--port PORT] [--runtime-mode container|native] [--memory-profile low|balanced] [--adaptive-memory|--no-adaptive-memory] [--skip-agents] [--no-start] [--personal-only|--with-dev-public]
23
25
  connect-workspace --url URL --token-file FILE [--data-dir DIR]
26
+ peer status|host|invite|join|share|revoke|off [--data-dir DIR] (LAN roundtable Beta: pair devices and share project leads)
24
27
  remote-mcp --personal-project-id ID --bearer-token-file FILE [--port PORT] [--data-dir DIR] [--source-application claude|cursor|other] [--allowed-origin HTTPS_ORIGIN] [--allowed-host HOST] [--max-sessions N] [--session-idle-ttl-seconds N]
25
28
  employee install BUILT_PACKAGE_DIRECTORY [--replace] [--runtime-config FILE]
26
29
  update [setup options]