groove-dev 0.27.196 → 0.27.198

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 (106) hide show
  1. package/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
  2. package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
  3. package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +5 -5
  7. package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
  8. package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
  9. package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
  10. package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
  11. package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
  12. package/node_modules/@groove-dev/daemon/src/index.js +14 -2
  13. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
  14. package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
  15. package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
  16. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  17. package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
  18. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  19. package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
  20. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  21. package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
  22. package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
  23. package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
  24. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
  25. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
  26. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
  27. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
  28. package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
  29. package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -0
  31. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  32. package/node_modules/@groove-dev/gui/package.json +1 -1
  33. package/node_modules/@groove-dev/gui/src/App.jsx +2 -2
  34. package/node_modules/@groove-dev/gui/src/app.css +53 -0
  35. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
  36. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
  37. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
  38. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  39. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
  40. package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
  41. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
  42. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
  43. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
  44. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
  45. package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
  46. package/package.json +2 -2
  47. package/packages/cli/package.json +1 -1
  48. package/packages/daemon/package.json +1 -1
  49. package/packages/daemon/src/api.js +5 -5
  50. package/packages/daemon/src/axom-connector.js +340 -0
  51. package/packages/daemon/src/axom-install.js +140 -0
  52. package/packages/daemon/src/axom-server.js +229 -0
  53. package/packages/daemon/src/chatstore.js +104 -0
  54. package/packages/daemon/src/federation.js +6 -0
  55. package/packages/daemon/src/index.js +14 -2
  56. package/packages/daemon/src/innerchat-docs.js +8 -1
  57. package/packages/daemon/src/innerchat-relay.js +89 -0
  58. package/packages/daemon/src/innerchat.js +261 -1
  59. package/packages/daemon/src/introducer.js +1 -1
  60. package/packages/daemon/src/network-guard.js +56 -0
  61. package/packages/daemon/src/process.js +2 -2
  62. package/packages/daemon/src/providers/axom.js +66 -0
  63. package/packages/daemon/src/providers/index.js +2 -0
  64. package/packages/daemon/src/routes/agents.js +1 -175
  65. package/packages/daemon/src/routes/axom.js +196 -0
  66. package/packages/daemon/src/routes/chat-history.js +34 -0
  67. package/packages/daemon/src/routes/innerchat.js +152 -20
  68. package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
  69. package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
  70. package/packages/gui/dist/index.html +2 -2
  71. package/packages/gui/package.json +1 -1
  72. package/packages/gui/src/App.jsx +2 -2
  73. package/packages/gui/src/app.css +53 -0
  74. package/packages/gui/src/components/agents/agent-config.jsx +2 -2
  75. package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
  76. package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
  77. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  78. package/packages/gui/src/components/layout/command-palette.jsx +1 -1
  79. package/packages/gui/src/stores/groove.js +24 -163
  80. package/packages/gui/src/stores/slices/agents-slice.js +52 -2
  81. package/packages/gui/src/stores/slices/axom-slice.js +197 -0
  82. package/packages/gui/src/views/axom.jsx +1060 -0
  83. package/packages/gui/src/views/marketplace.jsx +1 -1
  84. package/packages/gui/src/views/settings.jsx +178 -64
  85. package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
  86. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
  87. package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  88. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
  89. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
  90. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
  91. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
  92. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
  93. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
  94. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
  95. package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
  96. package/packages/daemon/src/conversations.js +0 -576
  97. package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
  98. package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  99. package/packages/gui/src/components/chat/chat-header.jsx +0 -198
  100. package/packages/gui/src/components/chat/chat-input.jsx +0 -367
  101. package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
  102. package/packages/gui/src/components/chat/chat-view.jsx +0 -212
  103. package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
  104. package/packages/gui/src/components/chat/model-picker.jsx +0 -201
  105. package/packages/gui/src/stores/slices/chat-slice.js +0 -227
  106. package/packages/gui/src/views/chat.jsx +0 -6
@@ -0,0 +1,196 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ import os from 'os';
4
+ import { saveConfig } from '../firstrun.js';
5
+ import { validateEndpoint, AXOM_DEFAULT_PORT } from '../axom-connector.js';
6
+ import { hardwareReport } from '../axom-server.js';
7
+
8
+ // Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
9
+ // `truncated` flag in the response); we allow headroom and let the runtime be
10
+ // the authority on truncation — its flag is the honest signal, not ours.
11
+ const MAX_INTERRUPT_CHARS = 8000;
12
+
13
+ export function registerAxomRoutes(app, daemon) {
14
+ app.get('/api/axom/status', (req, res) => {
15
+ res.json(daemon.axom.status());
16
+ });
17
+
18
+ app.get('/api/axom/sessions', (req, res) => {
19
+ const sessions = daemon.axom.status().endpoints.flatMap((ep) =>
20
+ ep.sessions.map((s) => ({ endpoint: ep.name, ...s })));
21
+ res.json(sessions);
22
+ });
23
+
24
+ // In-daemon ring backfill for the GUI (initial load / scrubber). `since` is
25
+ // an envelope id ("ev-000123"); events after it are returned.
26
+ app.get('/api/axom/sessions/:id/events', (req, res) => {
27
+ try {
28
+ const sinceSeq = /^ev-\d+$/.test(req.query.since || '')
29
+ ? parseInt(req.query.since.slice(3), 10) : 0;
30
+ res.json(daemon.axom.events(req.query.endpoint, req.params.id, sinceSeq));
31
+ } catch (err) {
32
+ res.status(404).json({ error: err.message });
33
+ }
34
+ });
35
+
36
+ // §12: message starts a turn. Contract statuses (202/409/413) mirror
37
+ // through verbatim — the GUI reads them, we don't reinterpret.
38
+ app.post('/api/axom/sessions/:id/message', async (req, res) => {
39
+ try {
40
+ const { text, endpoint } = req.body || {};
41
+ if (!text || typeof text !== 'string' || !text.trim()) {
42
+ return res.status(400).json({ error: 'text is required' });
43
+ }
44
+ const result = await daemon.axom.message(endpoint, req.params.id, text);
45
+ daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
46
+ res.status(result.status).json(result.body);
47
+ } catch (err) {
48
+ res.status(502).json({ error: err.message });
49
+ }
50
+ });
51
+
52
+ app.post('/api/axom/sessions/:id/interrupt', async (req, res) => {
53
+ try {
54
+ const { text, endpoint } = req.body || {};
55
+ if (!text || typeof text !== 'string' || !text.trim()) {
56
+ return res.status(400).json({ error: 'text is required' });
57
+ }
58
+ if (text.length > MAX_INTERRUPT_CHARS) {
59
+ return res.status(400).json({ error: `text exceeds ${MAX_INTERRUPT_CHARS} chars` });
60
+ }
61
+ const result = await daemon.axom.interrupt(endpoint, req.params.id, text);
62
+ daemon.audit.log('axom.interrupt', { session: req.params.id, chars: text.length });
63
+ res.json(result); // verbatim {id, truncated} from the runtime
64
+ } catch (err) {
65
+ res.status(502).json({ error: err.message });
66
+ }
67
+ });
68
+
69
+ app.post('/api/axom/sessions/:id/stop', async (req, res) => {
70
+ try {
71
+ const result = await daemon.axom.stop(req.body?.endpoint, req.params.id);
72
+ daemon.audit.log('axom.stop', { session: req.params.id });
73
+ res.json(result);
74
+ } catch (err) {
75
+ res.status(502).json({ error: err.message });
76
+ }
77
+ });
78
+
79
+ // Machine readiness for local inference — powers the welcome page's
80
+ // "can this device run Axom?" verdict.
81
+ app.get('/api/axom/hardware', (req, res) => {
82
+ res.json(hardwareReport(daemon.grooveDir));
83
+ });
84
+
85
+ // This GROOVE's own Axom endpoint — what another instance would paste.
86
+ // serve binds 127.0.0.1 (§6), so cross-machine reach is the user's own
87
+ // channel; we hand them the exact tunnel one-liner rather than pretending
88
+ // the URL travels on its own.
89
+ app.get('/api/axom/my-endpoint', (req, res) => {
90
+ const running = daemon.axomServer.list().find((i) => i.status === 'running');
91
+ const port = running?.port || AXOM_DEFAULT_PORT;
92
+ const host = os.hostname();
93
+ const user = os.userInfo().username;
94
+ res.json({
95
+ url: `http://127.0.0.1:${port}`,
96
+ port,
97
+ host,
98
+ running: !!running,
99
+ instanceId: running?.id || null,
100
+ tunnelCommand: `ssh -N -L ${port}:localhost:${port} ${user}@${host}`,
101
+ });
102
+ });
103
+
104
+ // ── Managed local instances (contract §11) ──────────────────────────────
105
+
106
+ app.get('/api/axom/instances', (req, res) => {
107
+ res.json(daemon.axomServer.list());
108
+ });
109
+
110
+ app.post('/api/axom/instances', async (req, res) => {
111
+ try {
112
+ const instance = await daemon.axomServer.start(req.body?.id || 'default');
113
+ res.json(instance);
114
+ } catch (err) {
115
+ res.status(500).json({ error: err.message });
116
+ }
117
+ });
118
+
119
+ app.delete('/api/axom/instances/:id', async (req, res) => {
120
+ try {
121
+ await daemon.axomServer.stop(req.params.id);
122
+ res.json({ ok: true });
123
+ } catch (err) {
124
+ res.status(400).json({ error: err.message });
125
+ }
126
+ });
127
+
128
+ // ── Manifest-driven install (contract §11) ──────────────────────────────
129
+
130
+ app.get('/api/axom/install', (req, res) => {
131
+ res.json(daemon.axomInstaller.getStatus());
132
+ });
133
+
134
+ app.post('/api/axom/install', (req, res) => {
135
+ // Long-running — kick off and let the axom:install:progress broadcasts
136
+ // carry the story; the GUI polls GET /api/axom/install as backstop.
137
+ daemon.axomInstaller.install(req.body?.manifestUrl).catch(() => { /* status carries the error */ });
138
+ res.json({ started: true });
139
+ });
140
+
141
+ app.get('/api/axom/config', (req, res) => {
142
+ res.json({ endpoints: daemon.config.axom?.endpoints || [] });
143
+ });
144
+
145
+ app.patch('/api/axom/config', (req, res) => {
146
+ const { endpoints, command, mock, manifestUrl, modelDir } = req.body || {};
147
+
148
+ // Runtime settings (contract §11): serve binary path, mock mode,
149
+ // install-manifest URL, model dir. Any subset may be patched alone.
150
+ const settings = {};
151
+ if (command !== undefined) {
152
+ if (command !== null && (typeof command !== 'string' || !command.trim())) {
153
+ return res.status(400).json({ error: 'command must be a non-empty string or null' });
154
+ }
155
+ settings.command = command;
156
+ }
157
+ if (mock !== undefined) settings.mock = !!mock;
158
+ if (manifestUrl !== undefined) {
159
+ if (manifestUrl !== null && !/^https?:\/\//.test(manifestUrl)) {
160
+ return res.status(400).json({ error: 'manifestUrl must be http(s) or null' });
161
+ }
162
+ settings.manifestUrl = manifestUrl;
163
+ }
164
+ if (modelDir !== undefined) settings.modelDir = modelDir;
165
+ if (Object.keys(settings).length > 0) {
166
+ daemon.config.axom = { ...(daemon.config.axom || {}), ...settings };
167
+ if (endpoints === undefined) {
168
+ saveConfig(daemon.grooveDir, daemon.config);
169
+ daemon.audit.log('axom.config', { keys: Object.keys(settings) });
170
+ return res.json({ ...daemon.config.axom });
171
+ }
172
+ }
173
+
174
+ if (!Array.isArray(endpoints)) {
175
+ return res.status(400).json({ error: 'endpoints array is required' });
176
+ }
177
+ if (endpoints.length > 16) {
178
+ return res.status(400).json({ error: 'too many endpoints (max 16)' });
179
+ }
180
+ const seen = new Set();
181
+ for (const entry of endpoints) {
182
+ const problem = validateEndpoint(entry);
183
+ if (problem) return res.status(400).json({ error: problem });
184
+ if (seen.has(entry.name)) {
185
+ return res.status(400).json({ error: `duplicate endpoint name: ${entry.name}` });
186
+ }
187
+ seen.add(entry.name);
188
+ }
189
+ const cleaned = endpoints.map(({ name, url }) => ({ name, url: url.replace(/\/+$/, '') }));
190
+ daemon.config.axom = { ...(daemon.config.axom || {}), endpoints: cleaned };
191
+ saveConfig(daemon.grooveDir, daemon.config);
192
+ daemon.axom.configure(cleaned);
193
+ daemon.audit.log('axom.config', { endpoints: cleaned.map((e) => e.name) });
194
+ res.json({ endpoints: cleaned });
195
+ });
196
+ }
@@ -0,0 +1,34 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+
3
+ export function registerChatHistoryRoutes(app, daemon) {
4
+ // Full history for all agents — the GUI loads this on connect so chats are
5
+ // present regardless of which origin/port the tunnel came up on.
6
+ app.get('/api/chat-history', (req, res) => {
7
+ res.json({ history: daemon.chatStore.getAll() });
8
+ });
9
+
10
+ // Append a single message for an agent.
11
+ app.post('/api/chat-history/:agentId', (req, res) => {
12
+ const { message } = req.body || {};
13
+ if (!message || typeof message !== 'object') {
14
+ return res.status(400).json({ error: 'message object required' });
15
+ }
16
+ daemon.chatStore.append(req.params.agentId, message);
17
+ res.json({ ok: true });
18
+ });
19
+
20
+ // Replace an agent's whole history (batch sync).
21
+ app.put('/api/chat-history/:agentId', (req, res) => {
22
+ const { messages } = req.body || {};
23
+ if (!Array.isArray(messages)) {
24
+ return res.status(400).json({ error: 'messages array required' });
25
+ }
26
+ daemon.chatStore.replace(req.params.agentId, messages);
27
+ res.json({ ok: true });
28
+ });
29
+
30
+ app.delete('/api/chat-history/:agentId', (req, res) => {
31
+ daemon.chatStore.remove(req.params.agentId);
32
+ res.json({ ok: true });
33
+ });
34
+ }
@@ -1,6 +1,8 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
2
 
3
3
  import { MAX_EXCHANGES } from '../innerchat.js';
4
+ import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
5
+ import { saveConfig } from '../firstrun.js';
4
6
 
5
7
  // Agents know each other by name, not id. Exact matches win first so
6
8
  // `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
@@ -22,14 +24,16 @@ function resolveAgent(daemon, ref) {
22
24
  return partial.length === 1 ? partial[0] : null;
23
25
  }
24
26
 
25
- // Resolve the from/to pair from a request body, or write the appropriate
26
- // 400/404 and return null so the caller bails.
27
- function resolveParties(daemon, req, res) {
28
- const { from, to, message } = req.body || {};
29
-
27
+ // The calling agent is always local. Writes a 404 and returns null on miss.
28
+ function resolveFrom(daemon, from, res) {
30
29
  const fromAgent = resolveAgent(daemon, from);
31
30
  if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
31
+ return fromAgent;
32
+ }
32
33
 
34
+ // Resolve a LOCAL to/message pair, or write the appropriate 400/404 and return
35
+ // null. Used only when `to` is a plain (non-peer) name.
36
+ function resolveLocalTo(daemon, fromAgent, to, message, res) {
33
37
  const toAgent = resolveAgent(daemon, to);
34
38
  if (!toAgent) {
35
39
  const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
@@ -42,32 +46,63 @@ function resolveParties(daemon, req, res) {
42
46
  }
43
47
  return null;
44
48
  }
45
-
46
49
  if (!message || typeof message !== 'string' || !message.trim()) {
47
50
  res.status(400).json({ error: 'message is required' }); return null;
48
51
  }
49
- return { fromAgent, toAgent, message: message.trim() };
52
+ return { toAgent, message: message.trim() };
53
+ }
54
+
55
+ // Confirm a relay request is from a configured, signature-verified peer.
56
+ // Returns the peer record, or writes a 403 and returns null.
57
+ function verifyRelayCaller(daemon, body, res) {
58
+ const { payload, signature } = body || {};
59
+ if (!payload || typeof payload !== 'object' || !signature) {
60
+ res.status(400).json({ error: 'signed relay envelope required ({ payload, signature })' });
61
+ return null;
62
+ }
63
+ const fromDaemonId = payload.fromDaemonId;
64
+ const peer = daemon.innerchat._peerByDaemonId(fromDaemonId);
65
+ if (!peer) {
66
+ res.status(403).json({ error: `Unknown daemon "${String(fromDaemonId).slice(0, 16)}" — not a configured InnerChat peer.` });
67
+ return null;
68
+ }
69
+ if (!daemon.federation.verify(fromDaemonId, payload, signature)) {
70
+ res.status(403).json({ error: 'Signature verification failed.' });
71
+ return null;
72
+ }
73
+ return peer;
50
74
  }
51
75
 
52
76
  export function registerInnerChatRoutes(app, daemon) {
53
77
  /**
54
78
  * Ask another agent a question and BLOCK until it answers.
55
79
  *
56
- * This request is held open deliberately the calling agent is waiting on
57
- * it, and the response body is the other agent's reply. Best for tight
58
- * interface negotiation, where the finite exchange budget keeps both sides
59
- * writing decision-dense messages.
80
+ * `to` may be a local name or `name@peer`. A peer target is relayed to the
81
+ * configured peer daemon; the reply rides the still-open response back.
60
82
  */
61
83
  app.post('/api/innerchat/ask', async (req, res) => {
62
84
  try {
63
- const parties = resolveParties(daemon, req, res);
64
- if (!parties) return;
85
+ const { from, to, message } = req.body || {};
86
+ const fromAgent = resolveFrom(daemon, from, res);
87
+ if (!fromAgent) return;
65
88
 
66
89
  // Held open until the target answers — see the class doc in innerchat.js.
67
90
  req.setTimeout(0);
68
91
  res.setTimeout(0);
69
92
 
70
- const result = await daemon.innerchat.ask(parties.fromAgent.id, parties.toAgent.id, parties.message, {
93
+ if (daemon.innerchat.isRemoteRef(to)) {
94
+ const { name, alias } = parsePeerRef(to);
95
+ if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
96
+ const result = await daemon.innerchat.askRemote(fromAgent, name, alias, String(message).trim(), {
97
+ timeoutMs: req.body?.timeoutMs,
98
+ });
99
+ return res.json({ from: `${name}@${alias}`, reply: result.reply, threadId: result.threadId, remote: true });
100
+ }
101
+
102
+ const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
103
+ if (!parties) return;
104
+
105
+ const result = await daemon.innerchat.ask(fromAgent.id, parties.toAgent.id, parties.message, {
71
106
  timeoutMs: req.body?.timeoutMs,
72
107
  });
73
108
 
@@ -86,17 +121,34 @@ export function registerInnerChatRoutes(app, daemon) {
86
121
  });
87
122
 
88
123
  /**
89
- * Send a message WITHOUT blocking returns as soon as it's delivered. If the
90
- * target replies, the reply is routed back to the sender asynchronously
91
- * (resuming it if its turn ended). Best for handing off to a heads-down agent
92
- * where waiting out a timeout would waste the turn.
124
+ * Send a message WITHOUT blocking. `to` may be local or `name@peer`. For a
125
+ * peer target the reply (if any) is queued on the peer and picked up by this
126
+ * daemon's outbox poll, then routed back to the sender.
93
127
  */
94
128
  app.post('/api/innerchat/tell', async (req, res) => {
95
129
  try {
96
- const parties = resolveParties(daemon, req, res);
130
+ const { from, to, message } = req.body || {};
131
+ const fromAgent = resolveFrom(daemon, from, res);
132
+ if (!fromAgent) return;
133
+
134
+ if (daemon.innerchat.isRemoteRef(to)) {
135
+ const { name, alias } = parsePeerRef(to);
136
+ if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
137
+ const result = await daemon.innerchat.tellRemote(fromAgent, name, alias, String(message).trim());
138
+ return res.json({
139
+ ok: true,
140
+ to: `${name}@${alias}`,
141
+ delivered: result.delivered,
142
+ threadId: result.threadId,
143
+ remote: true,
144
+ note: `Message relayed to ${name}@${alias}. Its reply, if any, will be routed back to you — you can end your turn.`,
145
+ });
146
+ }
147
+
148
+ const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
97
149
  if (!parties) return;
98
150
 
99
- const result = await daemon.innerchat.tell(parties.fromAgent.id, parties.toAgent.id, parties.message, {
151
+ const result = await daemon.innerchat.tell(fromAgent.id, parties.toAgent.id, parties.message, {
100
152
  threadId: req.body?.threadId,
101
153
  });
102
154
 
@@ -115,6 +167,86 @@ export function registerInnerChatRoutes(app, daemon) {
115
167
  }
116
168
  });
117
169
 
170
+ // ── Cross-daemon relay endpoints (peer daemon → this daemon) ──
171
+
172
+ /**
173
+ * Receive a relayed ask/tell from a peer daemon. Signature-verified and
174
+ * restricted to configured peers. Resolves the target locally and delivers
175
+ * with a guest identity. For 'ask' the response is held open until the local
176
+ * agent answers.
177
+ */
178
+ app.post('/api/innerchat/relay', async (req, res) => {
179
+ const peer = verifyRelayCaller(daemon, req.body, res);
180
+ if (!peer) return;
181
+ const p = req.body.payload;
182
+ if (p.type !== 'innerchat.relay' || (p.kind !== 'ask' && p.kind !== 'tell')) {
183
+ return res.status(400).json({ error: 'invalid relay payload' });
184
+ }
185
+ try {
186
+ req.setTimeout(0);
187
+ res.setTimeout(0);
188
+ const result = await daemon.innerchat.receiveRelay({
189
+ fromName: p.fromName,
190
+ fromRole: p.fromRole,
191
+ fromDaemonId: p.fromDaemonId,
192
+ peerAlias: peer.alias,
193
+ toName: p.toName,
194
+ message: p.message,
195
+ kind: p.kind,
196
+ threadId: p.threadId,
197
+ timeoutMs: p.timeoutMs,
198
+ });
199
+ res.json(result);
200
+ } catch (err) {
201
+ const body = { error: err.message };
202
+ if (err.availableAgents) body.availableAgents = err.availableAgents;
203
+ res.status(404).json(body);
204
+ }
205
+ });
206
+
207
+ /**
208
+ * Drain this daemon's outbox of async (tell) replies queued for the calling
209
+ * peer. Signature-verified; returns and removes only that peer's entries.
210
+ */
211
+ app.post('/api/innerchat/relay/outbox', (req, res) => {
212
+ const peer = verifyRelayCaller(daemon, req.body, res);
213
+ if (!peer) return;
214
+ if (req.body.payload.type !== 'innerchat.outbox') {
215
+ return res.status(400).json({ error: 'invalid outbox payload' });
216
+ }
217
+ res.json({ entries: daemon.innerchat.drainOutbox(req.body.payload.fromDaemonId) });
218
+ });
219
+
220
+ // ── Peer configuration (alias ↔ url ↔ daemonId) ──────────────
221
+
222
+ app.get('/api/innerchat/peers', (req, res) => {
223
+ res.json({ peers: daemon.innerchat._peers() });
224
+ });
225
+
226
+ app.post('/api/innerchat/peers', (req, res) => {
227
+ const entry = { alias: req.body?.alias, url: req.body?.url, daemonId: req.body?.daemonId };
228
+ const err = validatePeer(entry);
229
+ if (err) return res.status(400).json({ error: err });
230
+ entry.url = entry.url.replace(/\/+$/, '');
231
+
232
+ const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
233
+ const next = peers.filter((p) => p.alias !== entry.alias);
234
+ next.push(entry);
235
+ daemon.config.innerchatPeers = next;
236
+ saveConfig(daemon.grooveDir, daemon.config);
237
+ daemon.audit.log('innerchat.peer.set', { alias: entry.alias, url: entry.url });
238
+ res.json({ peers: next });
239
+ });
240
+
241
+ app.delete('/api/innerchat/peers/:alias', (req, res) => {
242
+ const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
243
+ const next = peers.filter((p) => p.alias !== req.params.alias);
244
+ daemon.config.innerchatPeers = next;
245
+ saveConfig(daemon.grooveDir, daemon.config);
246
+ daemon.audit.log('innerchat.peer.remove', { alias: req.params.alias });
247
+ res.json({ peers: next });
248
+ });
249
+
118
250
  app.get('/api/innerchat/threads', (req, res) => {
119
251
  const { agentId } = req.query;
120
252
  res.json({ threads: daemon.innerchat.getThreads(agentId || null) });