groove-dev 0.27.206 → 0.27.208

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 (41) hide show
  1. package/node_modules/@groove-dev/cli/package.json +1 -1
  2. package/node_modules/@groove-dev/daemon/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/src/axom-connector.js +50 -2
  4. package/node_modules/@groove-dev/daemon/src/axom-remote.js +16 -14
  5. package/node_modules/@groove-dev/daemon/src/axom-runtimes.js +301 -0
  6. package/node_modules/@groove-dev/daemon/src/axom-server.js +23 -4
  7. package/node_modules/@groove-dev/daemon/src/chatstore.js +112 -21
  8. package/node_modules/@groove-dev/daemon/src/index.js +16 -1
  9. package/node_modules/@groove-dev/daemon/src/routes/agents.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -0
  11. package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +6 -3
  12. package/node_modules/@groove-dev/daemon/src/routes/watch.js +11 -0
  13. package/node_modules/@groove-dev/daemon/src/watcher.js +55 -2
  14. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  15. package/node_modules/@groove-dev/daemon/test/axom-runtimes.test.js +198 -0
  16. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +111 -19
  17. package/node_modules/@groove-dev/daemon/test/watcher.test.js +68 -2
  18. package/node_modules/@groove-dev/gui/dist/assets/{index-BYQ4qIHh.js → index-3Lzv2-To.js} +235 -235
  19. package/node_modules/@groove-dev/gui/dist/assets/index-Bh_HF8ed.css +1 -0
  20. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  21. package/node_modules/@groove-dev/gui/package.json +1 -1
  22. package/package.json +1 -1
  23. package/packages/cli/package.json +1 -1
  24. package/packages/daemon/package.json +1 -1
  25. package/packages/daemon/src/axom-connector.js +50 -2
  26. package/packages/daemon/src/axom-remote.js +16 -14
  27. package/packages/daemon/src/axom-runtimes.js +301 -0
  28. package/packages/daemon/src/axom-server.js +23 -4
  29. package/packages/daemon/src/chatstore.js +112 -21
  30. package/packages/daemon/src/index.js +16 -1
  31. package/packages/daemon/src/routes/agents.js +4 -0
  32. package/packages/daemon/src/routes/axom.js +73 -0
  33. package/packages/daemon/src/routes/chat-history.js +6 -3
  34. package/packages/daemon/src/routes/watch.js +11 -0
  35. package/packages/daemon/src/watcher.js +55 -2
  36. package/packages/gui/dist/assets/{index-BYQ4qIHh.js → index-3Lzv2-To.js} +235 -235
  37. package/packages/gui/dist/assets/index-Bh_HF8ed.css +1 -0
  38. package/packages/gui/dist/index.html +2 -2
  39. package/packages/gui/package.json +1 -1
  40. package/node_modules/@groove-dev/gui/dist/assets/index-DG6yq4dB.css +0 -1
  41. package/packages/gui/dist/assets/index-DG6yq4dB.css +0 -1
@@ -0,0 +1,301 @@
1
+ // GROOVE — Axom Runtime Model (the redesign's one entity)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // A `runtime` is the single thing the GUI reasons about: a named Axom with a
5
+ // URL and a control mode. Everything else (connector endpoints, spawned
6
+ // instances, SSH lifecycle, tunnels) is a backend behind this model.
7
+ //
8
+ // control: 'local' — a process THIS daemon spawns/kills (AxomServerManager)
9
+ // control: 'ssh' — start/stop over SSH on a machine the user owns
10
+ // control: 'none' — someone else's runtime; connect-only
11
+ //
12
+ // States, each owning one recovery action (plans/axom-runtime-flow-redesign.md):
13
+ // connected → events flowing (verb: stop / disconnect)
14
+ // running → /about answers, WS catching up (no verb needed)
15
+ // stopped → reachable, no runtime (verb: start, if controllable)
16
+ // unreachable → can't reach the URL/host (verb: heal tunnel / retry)
17
+ // `unknown` appears only when probing itself failed — never guessed away.
18
+
19
+ import { validateEndpoint } from './axom-connector.js';
20
+ import { validateRemote } from './axom-remote.js';
21
+ import { saveConfig } from './firstrun.js';
22
+
23
+ export function validateRuntime(rt) {
24
+ if (!rt || typeof rt !== 'object') return 'runtime must be an object';
25
+ if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
26
+ if (!rt.name || typeof rt.name !== 'string' || rt.name.length > 60) return 'invalid runtime name';
27
+ if (!['local', 'ssh', 'none'].includes(rt.control)) return 'control must be local, ssh, or none';
28
+ if (rt.control !== 'local' || rt.url) {
29
+ // local runtimes get their URL from the spawned port; others must have one
30
+ const problem = validateEndpoint({ name: rt.id, url: rt.url });
31
+ if (rt.control !== 'local' && problem) return problem;
32
+ }
33
+ if (rt.control === 'ssh') {
34
+ const problem = validateRemote({ port: undefined, ...rt.ssh });
35
+ if (problem) return `ssh config: ${problem}`;
36
+ }
37
+ if (rt.launch !== undefined) {
38
+ if (typeof rt.launch !== 'object' || typeof rt.launch.command !== 'string'
39
+ || rt.launch.command.length === 0 || rt.launch.command.length > 500) {
40
+ return 'launch.command must be a non-empty string of at most 500 chars';
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ export class AxomRuntimes {
47
+ constructor(daemon) {
48
+ this.daemon = daemon;
49
+ }
50
+
51
+ // ── Config ────────────────────────────────────────────────────────────────
52
+
53
+ _cfg() {
54
+ if (!this.daemon.config.axom) this.daemon.config.axom = {};
55
+ return this.daemon.config.axom;
56
+ }
57
+
58
+ list() {
59
+ return this._cfg().runtimes || [];
60
+ }
61
+
62
+ get(id) {
63
+ return this.list().find((r) => r.id === id) || null;
64
+ }
65
+
66
+ activeId() {
67
+ const cfg = this._cfg();
68
+ return cfg.activeRuntimeId && this.get(cfg.activeRuntimeId)
69
+ ? cfg.activeRuntimeId
70
+ : this.list()[0]?.id || null;
71
+ }
72
+
73
+ // One-time, idempotent: fold the four legacy keys into runtimes[]. Old keys
74
+ // are kept until the model proves out — compat routes still read them.
75
+ migrate() {
76
+ const cfg = this._cfg();
77
+ if (Array.isArray(cfg.runtimes)) return false;
78
+ const runtimes = [];
79
+ const remote = cfg.remote || null;
80
+ const ep = (cfg.endpoints || [])[0] || null;
81
+ if (ep && remote && ep.url.endsWith(`:${remote.port || 8737}`)) {
82
+ runtimes.push({
83
+ id: 'spark', name: remote.host.split('.')[0] || 'Remote', url: ep.url,
84
+ control: 'ssh',
85
+ ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
86
+ launch: remote.command ? { command: remote.command } : undefined,
87
+ logPath: remote.logPath,
88
+ });
89
+ } else if (ep) {
90
+ let name = 'Axom';
91
+ try { name = new URL(ep.url).hostname; } catch { /* keep default */ }
92
+ runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
93
+ }
94
+ cfg.runtimes = runtimes;
95
+ if (runtimes.length && !cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
96
+ return true;
97
+ }
98
+
99
+ _save() {
100
+ saveConfig(this.daemon.grooveDir, this.daemon.config);
101
+ }
102
+
103
+ start() {
104
+ this.migrate();
105
+ this._syncConnector();
106
+ }
107
+
108
+ // Every runtime with a URL becomes a connector endpoint — the connector
109
+ // stays the single owner of event streams.
110
+ _syncConnector() {
111
+ const entries = this.list()
112
+ .filter((r) => r.url)
113
+ .map((r) => ({ name: r.id, url: r.url }));
114
+ this.daemon.axom.configure(entries);
115
+ }
116
+
117
+ add(rt) {
118
+ const problem = validateRuntime(rt);
119
+ if (problem) throw new Error(problem);
120
+ if (this.get(rt.id)) throw new Error(`runtime "${rt.id}" already exists`);
121
+ this._cfg().runtimes = [...this.list(), rt];
122
+ if (!this._cfg().activeRuntimeId) this._cfg().activeRuntimeId = rt.id;
123
+ this._save();
124
+ this._syncConnector();
125
+ this.broadcastStatus();
126
+ return rt;
127
+ }
128
+
129
+ update(id, patch) {
130
+ const existing = this.get(id);
131
+ if (!existing) throw new Error(`no runtime "${id}"`);
132
+ const next = { ...existing, ...patch, id };
133
+ const problem = validateRuntime(next);
134
+ if (problem) throw new Error(problem);
135
+ this._cfg().runtimes = this.list().map((r) => (r.id === id ? next : r));
136
+ this._save();
137
+ this._syncConnector();
138
+ this.broadcastStatus();
139
+ return next;
140
+ }
141
+
142
+ remove(id) {
143
+ if (!this.get(id)) throw new Error(`no runtime "${id}"`);
144
+ this._cfg().runtimes = this.list().filter((r) => r.id !== id);
145
+ if (this._cfg().activeRuntimeId === id) {
146
+ this._cfg().activeRuntimeId = this.list()[0]?.id || null;
147
+ }
148
+ this._save();
149
+ this._syncConnector();
150
+ this.broadcastStatus();
151
+ }
152
+
153
+ activate(id) {
154
+ if (!this.get(id)) throw new Error(`no runtime "${id}"`);
155
+ this._cfg().activeRuntimeId = id;
156
+ this._save();
157
+ this.broadcastStatus();
158
+ }
159
+
160
+ // ── State derivation ──────────────────────────────────────────────────────
161
+
162
+ async state(id) {
163
+ const rt = this.get(id);
164
+ if (!rt) throw new Error(`no runtime "${id}"`);
165
+ const ep = rt.url ? this.daemon.axom.endpoints.get(rt.id) : null;
166
+
167
+ if (ep?.status === 'connected') {
168
+ return { state: 'connected', detail: null };
169
+ }
170
+ // Probe the URL directly — the connector's backoff may simply not have
171
+ // caught up yet, and "running" beats a stale "error".
172
+ if (rt.url) {
173
+ try {
174
+ const res = await fetch(`${rt.url}/about`, { signal: AbortSignal.timeout(3000) });
175
+ if (res.ok) {
176
+ // It answers — pull the connector in NOW instead of letting its
177
+ // backoff stretch the 'running' limbo into a felt stall.
178
+ this.daemon.axom.nudge?.(rt.id);
179
+ return { state: 'running', detail: 'connecting to event stream' };
180
+ }
181
+ } catch (err) {
182
+ const refused = /ECONNREFUSED/.test(err.cause?.code || err.message || '');
183
+ if (refused && rt.control !== 'ssh') {
184
+ return { state: 'stopped', detail: null };
185
+ }
186
+ }
187
+ }
188
+ if (rt.control === 'ssh') {
189
+ // The host knows more than the tunnel does.
190
+ const remote = await this.daemon.axomRemote.status(this._sshCfg(rt));
191
+ if (remote.running === true) return { state: 'unreachable', detail: 'runtime is up on the host — the tunnel is down' };
192
+ if (remote.running === false) return { state: 'stopped', detail: null };
193
+ return { state: 'unreachable', detail: remote.error || `can't reach ${rt.ssh.host}` };
194
+ }
195
+ if (rt.control === 'local') {
196
+ const inst = this.daemon.axomServer.list().find((i) => i.id === rt.id);
197
+ if (inst?.status === 'running') return { state: 'running', detail: 'connecting to event stream' };
198
+ return { state: 'stopped', detail: inst?.error || null };
199
+ }
200
+ return { state: 'unreachable', detail: 'nothing answers at this endpoint' };
201
+ }
202
+
203
+ _broadcasting = false;
204
+ async broadcastStatus() {
205
+ if (this._broadcasting) return; // status() probes; don't stampede
206
+ this._broadcasting = true;
207
+ try {
208
+ this.daemon.broadcast({ type: 'axom:runtimes', data: await this.status() });
209
+ } catch { /* next state change rebroadcasts */ } finally {
210
+ this._broadcasting = false;
211
+ }
212
+ }
213
+
214
+ async status() {
215
+ const runtimes = await Promise.all(this.list().map(async (rt) => {
216
+ let derived;
217
+ try {
218
+ derived = await this.state(rt.id);
219
+ } catch (err) {
220
+ derived = { state: 'unknown', detail: err.message };
221
+ }
222
+ const ep = this.daemon.axom.endpoints.get(rt.id);
223
+ return {
224
+ id: rt.id,
225
+ name: rt.name,
226
+ control: rt.control,
227
+ url: rt.url || null,
228
+ ...derived,
229
+ about: ep?.about || null,
230
+ error: ep?.error || null,
231
+ canStart: rt.control !== 'none' && derived.state === 'stopped',
232
+ canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
233
+ canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
234
+ };
235
+ }));
236
+ return { runtimes, activeRuntimeId: this.activeId() };
237
+ }
238
+
239
+ // ── Verbs — dispatch on control, never guess ─────────────────────────────
240
+
241
+ _sshCfg(rt) {
242
+ let port = 8737;
243
+ try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
244
+ return {
245
+ ...rt.ssh,
246
+ port,
247
+ command: rt.launch?.command,
248
+ logPath: rt.logPath,
249
+ };
250
+ }
251
+
252
+ async startRuntime(id) {
253
+ const rt = this.get(id);
254
+ if (!rt) throw new Error(`no runtime "${id}"`);
255
+ if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE — start it where it runs`);
256
+ if (rt.control === 'ssh') {
257
+ const result = await this.daemon.axomRemote.start(this._sshCfg(rt));
258
+ if (rt.ssh?.autoTunnel !== false) await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
259
+ this._syncConnector();
260
+ this.daemon.axom.nudge?.(rt.id);
261
+ this.broadcastStatus();
262
+ return result;
263
+ }
264
+ // local: the spawned port becomes the runtime's URL.
265
+ const instance = await this.daemon.axomServer.start(rt.id, {
266
+ launch: rt.launch,
267
+ dataDir: rt.dataDir,
268
+ });
269
+ this.update(id, { url: `http://127.0.0.1:${instance.port}` });
270
+ this.daemon.axom.nudge?.(rt.id);
271
+ return { started: true, port: instance.port };
272
+ }
273
+
274
+ async stopRuntime(id, { force = false } = {}) {
275
+ const rt = this.get(id);
276
+ if (!rt) throw new Error(`no runtime "${id}"`);
277
+ if (rt.control === 'none') throw new Error(`"${rt.name}" is not controlled by GROOVE`);
278
+ let result;
279
+ if (rt.control === 'ssh') {
280
+ result = await this.daemon.axomRemote.stop({ force }, this._sshCfg(rt));
281
+ } else {
282
+ await this.daemon.axomServer.stop(rt.id);
283
+ result = { stopped: true };
284
+ }
285
+ // The connector still believes 'connected' until its next poll fails —
286
+ // re-probe now so the runtime card moves with the verb, not the poll.
287
+ this.daemon.axom.recheck?.(rt.id);
288
+ this.broadcastStatus();
289
+ return result;
290
+ }
291
+
292
+ async heal(id) {
293
+ const rt = this.get(id);
294
+ if (!rt) throw new Error(`no runtime "${id}"`);
295
+ if (rt.control !== 'ssh') throw new Error('only ssh runtimes have a tunnel to heal');
296
+ const result = await this.daemon.axomRemote.ensureTunnel(this._sshCfg(rt));
297
+ this.daemon.axom.nudge?.(rt.id);
298
+ this.broadcastStatus();
299
+ return result;
300
+ }
301
+ }
@@ -106,7 +106,14 @@ export class AxomServerManager {
106
106
 
107
107
  // Start a local instance. `id` doubles as the data-dir name, so the same id
108
108
  // across restarts resumes the same sovereign memory.
109
- async start(id = 'default') {
109
+ // opts.launch: {command, cwd?, env?} — a SPEC, not a binary name (source
110
+ // checkouts launch as `python3 -u -m axom.cli ...` with cwd+env; the
111
+ // installed default is just `axom`). Specs are verbatim: GROOVE never adds
112
+ // or removes flags (--cpu on the Spark is policy, not ours to "improve").
113
+ // opts.dataDir: adopt an existing sovereign ledger instead of minting one
114
+ // under .groove — one central ledger per user; sessions are recency
115
+ // scopes, never memory walls (SPARK_DEV_SETUP.md ruling).
116
+ async start(id = 'default', opts = {}) {
110
117
  if (!/^[a-zA-Z0-9_-]{1,40}$/.test(id)) throw new Error('invalid instance id');
111
118
  const existing = this.instances.get(id);
112
119
  if (existing && existing.status === 'running') return this.list().find((i) => i.id === id);
@@ -124,7 +131,7 @@ export class AxomServerManager {
124
131
  }
125
132
 
126
133
  const port = this._allocatePort();
127
- const dataDir = join(this.daemon.grooveDir, 'axom', 'instances', id);
134
+ const dataDir = opts.dataDir || join(this.daemon.grooveDir, 'axom', 'instances', id);
128
135
  mkdirSync(dataDir, { recursive: true });
129
136
 
130
137
  const args = [
@@ -144,9 +151,21 @@ export class AxomServerManager {
144
151
  this.instances.set(id, instance);
145
152
  this._broadcast();
146
153
 
154
+ const launch = opts.launch || { command: this._command() };
147
155
  let proc;
148
156
  try {
149
- proc = spawn(this._command(), args, { stdio: ['ignore', 'pipe', 'pipe'] });
157
+ if (launch.cwd || launch.env || /\s/.test(launch.command)) {
158
+ // Compound commands run through bash -lc — `nohup cd x && prog`-class
159
+ // failures taught us a spec is a shell line, not an argv[0].
160
+ const quotedArgs = args.map((a) => `'${String(a).replace(/'/g, `'\\''`)}'`).join(' ');
161
+ proc = spawn('bash', ['-lc', `exec ${launch.command} ${quotedArgs}`], {
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ cwd: launch.cwd || undefined,
164
+ env: launch.env ? { ...process.env, ...launch.env } : process.env,
165
+ });
166
+ } else {
167
+ proc = spawn(launch.command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
168
+ }
150
169
  } catch (err) {
151
170
  instance.status = 'error';
152
171
  instance.error = err.message;
@@ -159,7 +178,7 @@ export class AxomServerManager {
159
178
  proc.on('error', (err) => {
160
179
  instance.status = 'error';
161
180
  instance.error = err.code === 'ENOENT'
162
- ? `"${this._command()}" not found — install the Axom runtime first`
181
+ ? `"${launch.command}" not found — install the Axom runtime first`
163
182
  : err.message;
164
183
  this._broadcast();
165
184
  });
@@ -9,14 +9,21 @@ const MAX_PER_AGENT = 200;
9
9
  const SAVE_DEBOUNCE_MS = 1500;
10
10
 
11
11
  /**
12
- * Server-side chat history.
12
+ * Server-side chat history, keyed by AGENT NAME.
13
13
  *
14
- * The GUI used to keep chat history only in the browser's localStorage, which
15
- * is scoped per origin (scheme://host:PORT). For a remote GUI reached over an
16
- * SSH tunnel the local port changes across reconnects, so the origin — and thus
17
- * the entire chat store — changed out from under the user, stranding history on
18
- * old ports. Keeping it on the daemon makes it independent of port, origin, and
19
- * even which machine connects: reconnect from anywhere and the chats are there.
14
+ * Two hard-won identity lessons live here:
15
+ *
16
+ * 1. localStorage is per-origin (scheme://host:PORT), and a tunnelled GUI's
17
+ * port changes across reconnects — so history kept only in the browser gets
18
+ * stranded on dead origins. Hence a server-side store at all.
19
+ *
20
+ * 2. Agent IDs are NOT stable: every rotation/resume mints a new id. A store
21
+ * keyed by id fragments on every rotation — the full history stays under
22
+ * the dead id while the new id starts near-empty, and a reconnect then
23
+ * "restores" that near-empty stub over the user's real history. The agent
24
+ * NAME survives rotation (the Watch system keys by name for the same
25
+ * reason), so name is the identity here. IDs are accepted at the API edge
26
+ * and resolved immediately.
20
27
  */
21
28
  export class ChatStore {
22
29
  constructor(daemon) {
@@ -36,6 +43,50 @@ export class ChatStore {
36
43
  return {};
37
44
  }
38
45
 
46
+ // Resolve an id-or-name to the stable storage key (the agent's name).
47
+ // Unknown refs are used verbatim so nothing is ever dropped — an orphaned
48
+ // id key re-merges at boot once its agent is known, or stays parked.
49
+ _keyFor(ref) {
50
+ if (!ref) return null;
51
+ const byId = this.daemon.registry?.get?.(ref);
52
+ if (byId?.name) return byId.name;
53
+ return String(ref);
54
+ }
55
+
56
+ /**
57
+ * Fold any id-keyed entries into their agent's name key. Runs at boot (after
58
+ * registry restore) so stores written by the old id-keyed code reattach to
59
+ * the agent wherever the id is still resolvable.
60
+ */
61
+ migrate() {
62
+ const agents = this.daemon.registry?.getAll?.() || [];
63
+ const byId = new Map(agents.map((a) => [a.id, a]));
64
+ let moved = 0;
65
+ for (const key of Object.keys(this.history)) {
66
+ const agent = byId.get(key);
67
+ if (!agent || agent.name === key) continue;
68
+ this.history[agent.name] = mergeMessages(this.history[agent.name], this.history[key]);
69
+ delete this.history[key];
70
+ moved += 1;
71
+ }
72
+ if (moved) this._scheduleSave();
73
+ return moved;
74
+ }
75
+
76
+ /**
77
+ * Rotation-time hook: an entry parked under a dead id (written by the old
78
+ * id-keyed code, or by an old GUI posting under ids) follows the agent to
79
+ * its new identity.
80
+ */
81
+ remap(oldRef, newRef) {
82
+ const oldKey = String(oldRef);
83
+ const newKey = this._keyFor(newRef);
84
+ if (!newKey || oldKey === newKey || !this.history[oldKey]) return;
85
+ this.history[newKey] = mergeMessages(this.history[newKey], this.history[oldKey]);
86
+ delete this.history[oldKey];
87
+ this._scheduleSave();
88
+ }
89
+
39
90
  _scheduleSave() {
40
91
  if (this._saveTimer) return;
41
92
  this._saveTimer = setTimeout(() => {
@@ -64,35 +115,59 @@ export class ChatStore {
64
115
  return out;
65
116
  }
66
117
 
67
- append(agentId, message) {
68
- if (!agentId) return;
118
+ append(ref, message) {
119
+ const key = this._keyFor(ref);
120
+ if (!key) return;
69
121
  const clean = this._clean(message);
70
122
  if (!clean) return;
71
- const arr = this.history[agentId] || [];
123
+ const arr = this.history[key] || [];
72
124
  arr.push(clean);
73
- this.history[agentId] = arr.slice(-MAX_PER_AGENT);
125
+ this.history[key] = arr.slice(-MAX_PER_AGENT);
74
126
  this._scheduleSave();
75
127
  }
76
128
 
77
- // Replace an agent's whole history — used when the GUI syncs a batch (e.g.
78
- // messages it recorded while briefly disconnected).
79
- replace(agentId, messages) {
80
- if (!agentId || !Array.isArray(messages)) return;
81
- this.history[agentId] = messages.map((m) => this._clean(m)).filter(Boolean).slice(-MAX_PER_AGENT);
129
+ /**
130
+ * Merge a batch from a client. This is a UNION by (timestamp, from, text) —
131
+ * never a replace — so a browser holding messages the server missed adds
132
+ * them, and a browser with less than the server can never truncate it.
133
+ * A "restore" must be incapable of destroying what it restores from.
134
+ */
135
+ merge(ref, messages) {
136
+ const key = this._keyFor(ref);
137
+ if (!key || !Array.isArray(messages)) return;
138
+ const incoming = messages.map((m) => this._clean(m)).filter(Boolean);
139
+ this.history[key] = mergeMessages(this.history[key], incoming);
82
140
  this._scheduleSave();
83
141
  }
84
142
 
143
+ /**
144
+ * History for the GUI: live agents keyed by their CURRENT id (what the GUI
145
+ * looks up by), everything else under its stored name key so an agent
146
+ * respawned under the same name picks its history back up.
147
+ */
148
+ view() {
149
+ const out = {};
150
+ const agents = this.daemon.registry?.getAll?.() || [];
151
+ const liveNames = new Map(agents.map((a) => [a.name, a.id]));
152
+ for (const [key, msgs] of Object.entries(this.history)) {
153
+ if (!Array.isArray(msgs) || !msgs.length) continue;
154
+ out[liveNames.get(key) || key] = msgs;
155
+ }
156
+ return out;
157
+ }
158
+
85
159
  getAll() {
86
160
  return this.history;
87
161
  }
88
162
 
89
- get(agentId) {
90
- return this.history[agentId] || [];
163
+ get(ref) {
164
+ return this.history[this._keyFor(ref)] || [];
91
165
  }
92
166
 
93
- remove(agentId) {
94
- if (this.history[agentId]) {
95
- delete this.history[agentId];
167
+ remove(ref) {
168
+ const key = this._keyFor(ref);
169
+ if (key && this.history[key]) {
170
+ delete this.history[key];
96
171
  this._scheduleSave();
97
172
  }
98
173
  }
@@ -102,3 +177,19 @@ export class ChatStore {
102
177
  this._saveNow();
103
178
  }
104
179
  }
180
+
181
+ // Union of two message arrays, deduped on (timestamp, from, text), time-sorted,
182
+ // capped. Exported for tests and the migration path.
183
+ export function mergeMessages(a, b) {
184
+ const seen = new Set();
185
+ const out = [];
186
+ for (const m of [...(a || []), ...(b || [])]) {
187
+ if (!m || typeof m !== 'object') continue;
188
+ const sig = `${m.timestamp}:${m.from}:${typeof m.text === 'string' ? m.text.slice(0, 200) : ''}`;
189
+ if (seen.has(sig)) continue;
190
+ seen.add(sig);
191
+ out.push(m);
192
+ }
193
+ out.sort((x, y) => (x.timestamp || 0) - (y.timestamp || 0));
194
+ return out.slice(-MAX_PER_AGENT);
195
+ }
@@ -58,6 +58,7 @@ import { AxomConnector } from './axom-connector.js';
58
58
  import { AxomServerManager } from './axom-server.js';
59
59
  import { AxomInstaller } from './axom-install.js';
60
60
  import { AxomRemote } from './axom-remote.js';
61
+ import { AxomRuntimes } from './axom-runtimes.js';
61
62
  import { setProviderPaths } from './providers/index.js';
62
63
 
63
64
  const DEFAULT_PORT = 31415;
@@ -175,6 +176,7 @@ export class Daemon {
175
176
  this.axomServer = new AxomServerManager(this);
176
177
  this.axomInstaller = new AxomInstaller(this);
177
178
  this.axomRemote = new AxomRemote(this);
179
+ this.axomRuntimes = new AxomRuntimes(this);
178
180
  this.trajectoryCapture = null;
179
181
 
180
182
  // Hook teams.delete to clean up agent-loop session files
@@ -438,6 +440,12 @@ export class Daemon {
438
440
  }
439
441
 
440
442
  broadcast(message) {
443
+ // Every rotation/resume path announces itself here — the one chokepoint
444
+ // for "this agent has a new id". Chat history parked under the dead id
445
+ // (stale client posts, pre-migration data) follows the agent.
446
+ if (message.type === 'rotation:complete' && message.oldAgentId && message.agentId) {
447
+ try { this.chatStore.remap(message.oldAgentId, message.agentId); } catch { /* best effort */ }
448
+ }
441
449
  if (!this.wss) return;
442
450
  const payload = JSON.stringify(message);
443
451
  for (const client of this.wss.clients) {
@@ -636,10 +644,17 @@ export class Daemon {
636
644
  this.orchestrator.start();
637
645
  this.timeline.start();
638
646
  this.gateways.start();
639
- this.axom.start();
647
+ this.axomRuntimes.start();
640
648
  this.federation.initialize();
641
649
  this._startGarbageCollector();
642
650
 
651
+ // Fold id-keyed chat history into name keys now that the registry is
652
+ // restored — reattaches histories written by the old id-keyed store.
653
+ try {
654
+ const moved = this.chatStore.migrate();
655
+ if (moved) console.log(`[chat] migrated ${moved} id-keyed histories to agent names`);
656
+ } catch { /* best effort */ }
657
+
643
658
  // Regenerate the on-disk registry files once on boot. They otherwise
644
659
  // only refresh on a registry change, so a daemon upgraded with new
645
660
  // agent-facing docs (InnerChat, Watch) would serve stale AGENTS_REGISTRY.md
@@ -89,6 +89,10 @@ export function registerAgentRoutes(app, daemon) {
89
89
  // Killed/completed agents stay visible so the user can review output.
90
90
  const purge = req.query.purge === 'true';
91
91
  if (purge) {
92
+ // Clear chat history while the id still resolves to a name — the
93
+ // store is name-keyed, and a purged name's history must not haunt a
94
+ // future agent spawned under the same name.
95
+ try { daemon.chatStore.remove(agent.name); } catch { /* best effort */ }
92
96
  daemon.registry.remove(req.params.id);
93
97
  }
94
98
 
@@ -122,6 +122,79 @@ export function registerAxomRoutes(app, daemon) {
122
122
  }
123
123
  });
124
124
 
125
+ // ── Runtimes — the one entity (plans/axom-runtime-flow-redesign.md) ─────
126
+ // The GUI reasons about runtimes only; endpoints/instances/ssh are backends.
127
+
128
+ app.get('/api/axom/runtimes', async (req, res) => {
129
+ res.json(await daemon.axomRuntimes.status());
130
+ });
131
+
132
+ app.post('/api/axom/runtimes', (req, res) => {
133
+ try {
134
+ const rt = daemon.axomRuntimes.add(req.body);
135
+ if (req.body?.activate) daemon.axomRuntimes.activate(rt.id);
136
+ daemon.audit.log('axom.runtime.add', { id: rt.id, control: rt.control });
137
+ res.json(rt);
138
+ } catch (err) {
139
+ res.status(400).json({ error: err.message });
140
+ }
141
+ });
142
+
143
+ app.patch('/api/axom/runtimes/:id', (req, res) => {
144
+ try {
145
+ res.json(daemon.axomRuntimes.update(req.params.id, req.body || {}));
146
+ } catch (err) {
147
+ res.status(400).json({ error: err.message });
148
+ }
149
+ });
150
+
151
+ app.delete('/api/axom/runtimes/:id', (req, res) => {
152
+ try {
153
+ daemon.axomRuntimes.remove(req.params.id);
154
+ daemon.audit.log('axom.runtime.remove', { id: req.params.id });
155
+ res.json({ ok: true });
156
+ } catch (err) {
157
+ res.status(400).json({ error: err.message });
158
+ }
159
+ });
160
+
161
+ app.post('/api/axom/runtimes/:id/activate', (req, res) => {
162
+ try {
163
+ daemon.axomRuntimes.activate(req.params.id);
164
+ res.json({ ok: true, activeRuntimeId: req.params.id });
165
+ } catch (err) {
166
+ res.status(400).json({ error: err.message });
167
+ }
168
+ });
169
+
170
+ app.post('/api/axom/runtimes/:id/start', async (req, res) => {
171
+ try {
172
+ const result = await daemon.axomRuntimes.startRuntime(req.params.id);
173
+ daemon.audit.log('axom.runtime.start', { id: req.params.id });
174
+ res.json(result);
175
+ } catch (err) {
176
+ res.status(502).json({ error: err.message });
177
+ }
178
+ });
179
+
180
+ app.post('/api/axom/runtimes/:id/stop', async (req, res) => {
181
+ try {
182
+ const result = await daemon.axomRuntimes.stopRuntime(req.params.id, { force: !!req.body?.force });
183
+ daemon.audit.log('axom.runtime.stop', { id: req.params.id });
184
+ res.json(result);
185
+ } catch (err) {
186
+ res.status(502).json({ error: err.message });
187
+ }
188
+ });
189
+
190
+ app.post('/api/axom/runtimes/:id/heal', async (req, res) => {
191
+ try {
192
+ res.json(await daemon.axomRuntimes.heal(req.params.id));
193
+ } catch (err) {
194
+ res.status(502).json({ error: err.message });
195
+ }
196
+ });
197
+
125
198
  // ── Remote runtime control over SSH (manual only, never automatic) ──────
126
199
 
127
200
  app.get('/api/axom/remote', async (req, res) => {