groove-dev 0.27.208 → 0.27.209

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.208",
3
+ "version": "0.27.209",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.208",
3
+ "version": "0.27.209",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -20,6 +20,25 @@ import { validateEndpoint } from './axom-connector.js';
20
20
  import { validateRemote } from './axom-remote.js';
21
21
  import { saveConfig } from './firstrun.js';
22
22
 
23
+ // The blessed launch spec's env (SPARK_DEV_SETUP.md / §10). These change
24
+ // runtime BEHAVIOUR, not just paths — a spec missing AXOM_MAX_CTX boots a
25
+ // 2048-ctx runtime that looks fine and answers worse. Applied as DEFAULTS
26
+ // only: a value the user set in their own spec always wins, because "GROOVE
27
+ // never edits a spec's flags" is the standing ruling.
28
+ export const BLESSED_ENV = { AXOM_MAX_CTX: '8192' };
29
+
30
+ function withBlessedEnv(launch) {
31
+ if (!launch?.command) return launch;
32
+ return { ...launch, env: { ...BLESSED_ENV, ...(launch.env || {}) } };
33
+ }
34
+
35
+ // Single-quote for a POSIX shell. The spec is the user's own, but it crosses
36
+ // an ssh command line — an unquoted path or value must not be able to end the
37
+ // command and start another.
38
+ function shellQuote(s) {
39
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
40
+ }
41
+
23
42
  export function validateRuntime(rt) {
24
43
  if (!rt || typeof rt !== 'object') return 'runtime must be an object';
25
44
  if (!rt.id || !/^[a-zA-Z0-9_-]{1,40}$/.test(rt.id)) return 'invalid runtime id';
@@ -39,6 +58,18 @@ export function validateRuntime(rt) {
39
58
  || rt.launch.command.length === 0 || rt.launch.command.length > 500) {
40
59
  return 'launch.command must be a non-empty string of at most 500 chars';
41
60
  }
61
+ if (rt.launch.cwd !== undefined && typeof rt.launch.cwd !== 'string') {
62
+ return 'launch.cwd must be a string';
63
+ }
64
+ if (rt.launch.env !== undefined) {
65
+ if (typeof rt.launch.env !== 'object' || rt.launch.env === null || Array.isArray(rt.launch.env)) {
66
+ return 'launch.env must be an object of name/value pairs';
67
+ }
68
+ for (const [k, v] of Object.entries(rt.launch.env)) {
69
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) return `invalid env var name "${k}"`;
70
+ if (typeof v !== 'string' && typeof v !== 'number') return `env var "${k}" must be a string or number`;
71
+ }
72
+ }
42
73
  }
43
74
  return null;
44
75
  }
@@ -74,13 +105,26 @@ export class AxomRuntimes {
74
105
  // are kept until the model proves out — compat routes still read them.
75
106
  migrate() {
76
107
  const cfg = this._cfg();
77
- if (Array.isArray(cfg.runtimes)) return false;
108
+ // Marker, not mere presence of the array: an earlier migration could write
109
+ // an EMPTY runtimes[] and then never retry, stranding a configured host
110
+ // behind a first-run splash forever. Re-run until it produces something or
111
+ // there is genuinely nothing legacy left to fold.
112
+ if (cfg.runtimesMigrated) return false;
113
+ if (Array.isArray(cfg.runtimes) && cfg.runtimes.length) {
114
+ cfg.runtimesMigrated = true;
115
+ return false;
116
+ }
78
117
  const runtimes = [];
79
118
  const remote = cfg.remote || null;
80
119
  const ep = (cfg.endpoints || [])[0] || null;
81
- if (ep && remote && ep.url.endsWith(`:${remote.port || 8737}`)) {
120
+ // A remote host is a configured runtime whether or not an endpoint entry
121
+ // survives beside it — the endpoint list gets cleared by a disconnect, and
122
+ // dropping the host on that basis would strand a machine the user set up
123
+ // and show them a first-run splash instead.
124
+ if (remote?.host && (!ep || ep.url.endsWith(`:${remote.port || 8737}`))) {
82
125
  runtimes.push({
83
- id: 'spark', name: remote.host.split('.')[0] || 'Remote', url: ep.url,
126
+ id: 'spark', name: remote.host.split('.')[0] || 'Remote',
127
+ url: ep?.url || `http://127.0.0.1:${remote.port || 8737}`,
84
128
  control: 'ssh',
85
129
  ssh: { host: remote.host, user: remote.user, sshPort: remote.sshPort, autoTunnel: true },
86
130
  launch: remote.command ? { command: remote.command } : undefined,
@@ -92,7 +136,10 @@ export class AxomRuntimes {
92
136
  runtimes.push({ id: ep.name || 'axom', name, url: ep.url, control: 'none' });
93
137
  }
94
138
  cfg.runtimes = runtimes;
95
- if (runtimes.length && !cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
139
+ if (runtimes.length) {
140
+ cfg.runtimesMigrated = true;
141
+ if (!cfg.activeRuntimeId) cfg.activeRuntimeId = runtimes[0].id;
142
+ }
96
143
  return true;
97
144
  }
98
145
 
@@ -101,7 +148,9 @@ export class AxomRuntimes {
101
148
  }
102
149
 
103
150
  start() {
104
- this.migrate();
151
+ // Persist it: an unsaved migration re-derives on every boot, so a runtime
152
+ // the user later removed would come back from the legacy keys each time.
153
+ if (this.migrate()) this._save();
105
154
  this._syncConnector();
106
155
  }
107
156
 
@@ -231,20 +280,168 @@ export class AxomRuntimes {
231
280
  canStart: rt.control !== 'none' && derived.state === 'stopped',
232
281
  canStop: rt.control !== 'none' && (derived.state === 'connected' || derived.state === 'running'),
233
282
  canHeal: rt.control === 'ssh' && derived.state === 'unreachable',
283
+ ...this.generation(rt.id),
234
284
  };
235
285
  }));
236
286
  return { runtimes, activeRuntimeId: this.activeId() };
237
287
  }
238
288
 
289
+ // ── Mono-Axom (§10) ───────────────────────────────────────────────────────
290
+ //
291
+ // One Axom per user per machine. Every hook — a selector entry, a tab, a new
292
+ // chat — is a fresh SESSION on the one runtime, never a second process. The
293
+ // §14 lockfile is the enforcement mechanism, so racing hooks are safe: the
294
+ // loser is refused cleanly and joins the winner's runtime.
295
+
296
+ // Until multi-sequence lands, hooks share ONE generation slot. Concurrent
297
+ // work queues, and the UI is required to say so rather than looking hung.
298
+ generation(id) {
299
+ const ep = this.daemon.axom.endpoints.get(id);
300
+ const busySession = (ep?.sessions ? [...ep.sessions.values()] : []).find((s) => s.live);
301
+ return {
302
+ generationBusy: !!busySession,
303
+ generationHolder: busySession?.id || null,
304
+ // The holder's human name, if it is a chat this GROOVE minted. A session
305
+ // opened elsewhere (the REPL, another client) has none — the UI says
306
+ // "another session" rather than inventing one.
307
+ generationHolderLabel: busySession ? (this.getChat(busySession.id)?.label || null) : null,
308
+ };
309
+ }
310
+
311
+ // ── Chats — the persistent hook list ─────────────────────────────────────
312
+ //
313
+ // A chat is a named hook. It lives in daemon config, not the browser: tunnel
314
+ // ports move, tabs reload, and a chat list that evaporates on refresh reads
315
+ // as data loss even though the ledger kept everything.
316
+
317
+ chats() {
318
+ return (this._cfg().chats || []).filter((c) => !c.hidden);
319
+ }
320
+
321
+ getChat(session) {
322
+ return (this._cfg().chats || []).find((c) => c.session === session) || null;
323
+ }
324
+
325
+ _putChat(chat) {
326
+ const all = this._cfg().chats || [];
327
+ const i = all.findIndex((c) => c.session === chat.session);
328
+ this._cfg().chats = i >= 0 ? all.map((c) => (c.session === chat.session ? chat : c)) : [...all, chat];
329
+ this._save();
330
+ }
331
+
332
+ renameChat(session, label) {
333
+ const chat = this.getChat(session);
334
+ if (!chat) throw new Error(`no chat "${session}"`);
335
+ if (typeof label !== 'string' || !label.trim() || label.length > 80) {
336
+ throw new Error('label must be a non-empty string of at most 80 chars');
337
+ }
338
+ this._putChat({ ...chat, label: label.trim() });
339
+ this.broadcastChats();
340
+ return this.getChat(session);
341
+ }
342
+
343
+ // Hide, never delete. The conversation lives in the runtime's ledger and is
344
+ // the user's memory — GROOVE tidying its own list must never be able to
345
+ // destroy it. The session id is REMEMBERED so the connector's /sessions poll
346
+ // can't resurrect the row the user just cleared away.
347
+ hideChat(session) {
348
+ const chat = this.getChat(session);
349
+ if (!chat) throw new Error(`no chat "${session}"`);
350
+ this._putChat({ ...chat, hidden: true });
351
+ this.broadcastChats();
352
+ return { hidden: true, session, note: 'removed from the list; the conversation remains in Axom\'s memory' };
353
+ }
354
+
355
+ broadcastChats() {
356
+ this.daemon.broadcast({ type: 'axom:chats', data: { chats: this.chats() } });
357
+ }
358
+
359
+ // Idempotent by design: if the runtime already answers, this is a no-op. We
360
+ // never start a second process to satisfy a hook.
361
+ async ensureRunning(id) {
362
+ const { state } = await this.state(id);
363
+ if (state === 'connected' || state === 'running') return { started: false, alreadyRunning: true };
364
+ const rt = this.get(id);
365
+ if (rt.control === 'none') {
366
+ throw new Error(`"${rt.name}" runs on another machine — start it there, then hook in`);
367
+ }
368
+ try {
369
+ const result = await this.startRuntime(id);
370
+ return { ...result, started: result.started !== false };
371
+ } catch (err) {
372
+ // A racing hook that lost the §14 lock has NOT failed: the runtime it
373
+ // wanted is up, someone else just got there first. Re-derive rather
374
+ // than surfacing a lock error the user can do nothing about.
375
+ const after = await this.state(id);
376
+ if (after.state === 'connected' || after.state === 'running') {
377
+ return { started: false, alreadyRunning: true, wonBy: 'another hook' };
378
+ }
379
+ throw err;
380
+ }
381
+ }
382
+
383
+ async hook(id, { session, label } = {}) {
384
+ const rt = this.get(id || this.activeId());
385
+ if (!rt) throw new Error('no Axom runtime configured');
386
+ const launch = await this.ensureRunning(rt.id);
387
+ // §9: a hook mints its own session id — its own recency thread under the
388
+ // one identity. Callers may pass one to rejoin an existing thread.
389
+ const sessionId = session || `s-${Math.random().toString(36).slice(2, 10)}`;
390
+ // Persist the hook as a chat so the list survives a refresh. Rejoining an
391
+ // existing session must NOT un-hide a chat the user cleared away.
392
+ const existing = this.getChat(sessionId);
393
+ if (!existing) {
394
+ this._putChat({
395
+ session: sessionId,
396
+ runtimeId: rt.id,
397
+ label: label || `Chat ${this.chats().length + 1}`,
398
+ createdAt: Date.now(),
399
+ });
400
+ } else if (label && !existing.hidden) {
401
+ this._putChat({ ...existing, label });
402
+ }
403
+ this.broadcastChats();
404
+ this.broadcastStatus();
405
+ return {
406
+ runtimeId: rt.id,
407
+ name: rt.name,
408
+ url: rt.url,
409
+ session: sessionId,
410
+ label: this.getChat(sessionId)?.label || null,
411
+ launched: !!launch.started,
412
+ ...this.generation(rt.id),
413
+ };
414
+ }
415
+
239
416
  // ── Verbs — dispatch on control, never guess ─────────────────────────────
240
417
 
418
+ // A launch spec must mean exactly one thing in every control mode. The local
419
+ // path gets {cwd, env} as real spawn options; SSH has only a command string,
420
+ // so compose them INTO it here rather than dropping them — a spec whose env
421
+ // is silently ignored launches a subtly different runtime (wrong context
422
+ // window, wrong tree) while reporting success. Found in the wild: a spec
423
+ // without AXOM_MAX_CTX booted a 2048-ctx instance.
424
+ _sshCommand(rt) {
425
+ const launch = withBlessedEnv(rt.launch);
426
+ if (!launch?.command) return undefined;
427
+ const parts = [];
428
+ // `export`, not a `VAR=x prog` prefix: real specs are COMPOUND shell lines
429
+ // ("cd /x && prog"), and a prefix binds only to the first word — the var
430
+ // would decorate `cd` and never reach the runtime. Caught by its own test.
431
+ for (const [k, v] of Object.entries(launch.env || {})) {
432
+ parts.push(`export ${k}=${shellQuote(String(v))}; `);
433
+ }
434
+ if (launch.cwd) parts.push(`cd ${shellQuote(launch.cwd)} && `);
435
+ return `${parts.join('')}${launch.command}`;
436
+ }
437
+
241
438
  _sshCfg(rt) {
242
439
  let port = 8737;
243
440
  try { port = Number(new URL(rt.url).port) || 8737; } catch { /* default */ }
244
441
  return {
245
442
  ...rt.ssh,
246
443
  port,
247
- command: rt.launch?.command,
444
+ command: this._sshCommand(rt),
248
445
  logPath: rt.logPath,
249
446
  };
250
447
  }
@@ -263,7 +460,7 @@ export class AxomRuntimes {
263
460
  }
264
461
  // local: the spawned port becomes the runtime's URL.
265
462
  const instance = await this.daemon.axomServer.start(rt.id, {
266
- launch: rt.launch,
463
+ launch: withBlessedEnv(rt.launch) || { env: { ...BLESSED_ENV } },
267
464
  dataDir: rt.dataDir,
268
465
  });
269
466
  this.update(id, { url: `http://127.0.0.1:${instance.port}` });
@@ -151,7 +151,9 @@ export class AxomServerManager {
151
151
  this.instances.set(id, instance);
152
152
  this._broadcast();
153
153
 
154
- const launch = opts.launch || { command: this._command() };
154
+ // A spec may carry env/cwd without naming a command (the caller wanted the
155
+ // configured binary plus the blessed env) — fill the command, keep the rest.
156
+ const launch = { ...opts.launch, command: opts.launch?.command || this._command() };
155
157
  let proc;
156
158
  try {
157
159
  if (launch.cwd || launch.env || /\s/.test(launch.command)) {
@@ -158,6 +158,46 @@ export function registerAxomRoutes(app, daemon) {
158
158
  }
159
159
  });
160
160
 
161
+ // Mono-Axom (§10): a hook is a fresh session on the ONE runtime — never a
162
+ // second process. Used by the agent selector, new tabs, and new chats alike.
163
+ // Chats are named hooks, persisted daemon-side so the list survives a
164
+ // refresh. Removing one HIDES it — the conversation is the user's memory and
165
+ // lives in the runtime's ledger; GROOVE tidying its list never destroys it.
166
+ app.get('/api/axom/chats', (req, res) => {
167
+ res.json({ chats: daemon.axomRuntimes.chats() });
168
+ });
169
+
170
+ app.patch('/api/axom/chats/:session', (req, res) => {
171
+ try {
172
+ res.json(daemon.axomRuntimes.renameChat(req.params.session, req.body?.label));
173
+ } catch (err) {
174
+ res.status(400).json({ error: err.message });
175
+ }
176
+ });
177
+
178
+ app.delete('/api/axom/chats/:session', (req, res) => {
179
+ try {
180
+ const result = daemon.axomRuntimes.hideChat(req.params.session);
181
+ daemon.audit.log('axom.chat.hide', { session: req.params.session });
182
+ res.json(result);
183
+ } catch (err) {
184
+ res.status(400).json({ error: err.message });
185
+ }
186
+ });
187
+
188
+ app.post('/api/axom/hook', async (req, res) => {
189
+ try {
190
+ const result = await daemon.axomRuntimes.hook(req.body?.runtimeId, {
191
+ session: req.body?.session,
192
+ label: req.body?.label,
193
+ });
194
+ daemon.audit.log('axom.hook', { runtime: result.runtimeId, session: result.session });
195
+ res.json(result);
196
+ } catch (err) {
197
+ res.status(502).json({ error: err.message });
198
+ }
199
+ });
200
+
161
201
  app.post('/api/axom/runtimes/:id/activate', (req, res) => {
162
202
  try {
163
203
  daemon.axomRuntimes.activate(req.params.id);
@@ -75,6 +75,37 @@ describe('AxomRuntimes', () => {
75
75
  assert.equal(model.migrate(), false); // idempotent
76
76
  });
77
77
 
78
+ // Found in Ryan's live config: endpoints[] emptied by a disconnect, remote
79
+ // host still configured — the old rule dropped it and showed a first-run
80
+ // splash for a machine he had already set up.
81
+ it('migrates a configured remote host even with no endpoint entry beside it', () => {
82
+ daemon.config.axom = {
83
+ endpoints: [],
84
+ remote: { host: 'edgexpert-aaa6.local', user: 'axom', port: 8737, command: 'python3 -u -m axom.cli serve' },
85
+ };
86
+ model.migrate();
87
+ const [rt] = model.list();
88
+ assert.equal(rt.control, 'ssh');
89
+ assert.equal(rt.ssh.host, 'edgexpert-aaa6.local');
90
+ assert.equal(rt.url, 'http://127.0.0.1:8737');
91
+ });
92
+
93
+ it('retries a migration that produced nothing, but never re-derives a real one', () => {
94
+ // An earlier build wrote an empty runtimes[] and then early-returned
95
+ // forever on `Array.isArray` — the host stayed stranded across restarts.
96
+ daemon.config.axom = {
97
+ runtimes: [],
98
+ remote: { host: 'spark.local', user: 'axom', port: 8737 },
99
+ };
100
+ assert.equal(model.migrate(), true);
101
+ assert.equal(model.list().length, 1);
102
+ // Now it is marked done: a runtime the user removes must not resurrect.
103
+ assert.equal(model.migrate(), false);
104
+ model.remove('spark');
105
+ assert.equal(model.migrate(), false);
106
+ assert.equal(model.list().length, 0);
107
+ });
108
+
78
109
  it('migrates a lone endpoint into a connect-only runtime', () => {
79
110
  daemon.config.axom = { endpoints: [{ name: 'other', url: 'http://127.0.0.1:9999' }] };
80
111
  model.migrate();
@@ -89,7 +120,37 @@ describe('AxomRuntimes', () => {
89
120
  assert.ok(kinds.includes('tunnel')); // reachability follows lifecycle
90
121
  const cfg = daemon.calls.remote.find((c) => c[0] === 'start')[1];
91
122
  assert.equal(cfg.host, 'spark.local');
92
- assert.equal(cfg.command, SSH_RT.launch.command); // spec passed VERBATIM
123
+ // Spec passed verbatim, with only the blessed env exported ahead of it.
124
+ assert.ok(cfg.command.endsWith(SSH_RT.launch.command));
125
+ assert.match(cfg.command, /^export AXOM_MAX_CTX='8192'; /);
126
+ });
127
+
128
+ // A spec must mean the same thing in every control mode. SSH previously
129
+ // honoured only `command`, so structured cwd/env vanished — the class of bug
130
+ // that boots a 2048-ctx runtime from a spec that asked for 8192.
131
+ it('composes launch cwd and env into the ssh command instead of dropping them', async () => {
132
+ model.add({
133
+ ...SSH_RT,
134
+ launch: {
135
+ command: 'python3 -u -m axom.cli serve --cpu',
136
+ cwd: '/home/axom/Desktop/Axom/axom-release',
137
+ env: { PYTHONPATH: 'model', AXOM_MAX_CTX: '8192' },
138
+ },
139
+ });
140
+ await model.startRuntime('spark');
141
+ const { command } = daemon.calls.remote.find((c) => c[0] === 'start')[1];
142
+ // export before cd: a `VAR=x` prefix would bind to `cd`, not the runtime.
143
+ assert.match(command, /export PYTHONPATH='model';/);
144
+ assert.match(command, /cd '\/home\/axom\/Desktop\/Axom\/axom-release' && python3/);
145
+ assert.match(command, /AXOM_MAX_CTX='8192'/);
146
+ assert.ok(command.endsWith('python3 -u -m axom.cli serve --cpu'));
147
+ });
148
+
149
+ it('rejects launch env that is not clean name/value pairs', () => {
150
+ assert.ok(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: ['A=1'] } }));
151
+ assert.ok(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: { 'BAD NAME': '1' } } }));
152
+ assert.ok(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: { A: { nested: 1 } } } }));
153
+ assert.equal(validateRuntime({ ...SSH_RT, launch: { command: 'x', env: { AXOM_MAX_CTX: '8192' }, cwd: '/x' } }), null);
93
154
  });
94
155
 
95
156
  it('start dispatches by control: local spawns and adopts the resulting port as its URL', async () => {
@@ -136,6 +197,114 @@ describe('AxomRuntimes', () => {
136
197
  assert.equal(theirs.canHeal, false);
137
198
  });
138
199
 
200
+ // ── Mono-Axom (§10) ─────────────────────────────────────────────────────
201
+ // One Axom per machine; hooks are sessions, never processes.
202
+
203
+ it('a hook on a running runtime mints a session and starts nothing', async () => {
204
+ model.add(SSH_RT);
205
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
206
+ const a = await model.hook('spark');
207
+ const b = await model.hook('spark');
208
+ assert.match(a.session, /^s-/);
209
+ assert.notEqual(a.session, b.session); // each hook is its own recency thread
210
+ assert.equal(a.launched, false);
211
+ assert.equal(daemon.calls.remote.filter((c) => c[0] === 'start').length, 0);
212
+ });
213
+
214
+ it('a hook launches from the blessed spec only when nothing is running', async () => {
215
+ model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' });
216
+ fakeDaemon._remoteStatus = { running: false };
217
+ const h = await model.hook('spark');
218
+ delete fakeDaemon._remoteStatus;
219
+ assert.equal(h.launched, true);
220
+ assert.equal(daemon.calls.remote.filter((c) => c[0] === 'start').length, 1);
221
+ // The blessed env rides the launch — a 2048-ctx boot is the bug it prevents.
222
+ assert.match(daemon.calls.remote.find((c) => c[0] === 'start')[1].command, /AXOM_MAX_CTX='8192'/);
223
+ });
224
+
225
+ it('a hook that loses the lock race joins the winner instead of erroring', async () => {
226
+ model.add({ ...SSH_RT, url: 'http://127.0.0.1:1' });
227
+ let probes = 0;
228
+ fakeDaemon._remoteStatus = { running: false };
229
+ daemon.axomRemote.start = async () => { throw new Error('another instance holds the data-dir lock'); };
230
+ // The loser re-derives: by the time it asks again, the winner is up.
231
+ const realState = model.state.bind(model);
232
+ model.state = async (id) => (++probes >= 2 ? { state: 'connected', detail: null } : realState(id));
233
+ const h = await model.hook('spark');
234
+ delete fakeDaemon._remoteStatus;
235
+ assert.equal(h.launched, false);
236
+ assert.equal(h.wonBy, undefined); // hook() reports the session, not the race
237
+ assert.match(h.session, /^s-/);
238
+ });
239
+
240
+ it('a user-set env value beats the blessed default — GROOVE never edits a spec', async () => {
241
+ model.add({ ...SSH_RT, launch: { command: 'serve', env: { AXOM_MAX_CTX: '4096' } } });
242
+ await model.startRuntime('spark');
243
+ const { command } = daemon.calls.remote.find((c) => c[0] === 'start')[1];
244
+ assert.match(command, /AXOM_MAX_CTX='4096'/);
245
+ assert.doesNotMatch(command, /8192/);
246
+ });
247
+
248
+ it('reports the shared generation slot so concurrent hooks can queue honestly', async () => {
249
+ model.add(SSH_RT);
250
+ const sessions = new Map([
251
+ ['s-one', { id: 's-one', live: true }],
252
+ ['s-two', { id: 's-two', live: false }],
253
+ ]);
254
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions });
255
+ const { runtimes } = await model.status();
256
+ assert.equal(runtimes[0].generationBusy, true);
257
+ assert.equal(runtimes[0].generationHolder, 's-one');
258
+ sessions.get('s-one').live = false;
259
+ assert.equal(model.generation('spark').generationBusy, false);
260
+ });
261
+
262
+ it('refuses to hook a runtime on someone else\'s machine with an honest sentence', async () => {
263
+ model.add({ id: 'theirs', name: 'Theirs', url: 'http://127.0.0.1:1', control: 'none' });
264
+ await assert.rejects(() => model.hook('theirs'), /runs on another machine/);
265
+ });
266
+
267
+ // ── Chats — the persistent hook list ────────────────────────────────────
268
+
269
+ it('records every hook as a chat that survives a reload', async () => {
270
+ model.add(SSH_RT);
271
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
272
+ const a = await model.hook('spark', { label: 'Research' });
273
+ await model.hook('spark');
274
+ assert.equal(a.label, 'Research');
275
+ assert.equal(model.chats().length, 2);
276
+ // A fresh model over the same config sees them — the list is daemon-side.
277
+ assert.equal(new AxomRuntimes(daemon).chats().length, 2);
278
+ });
279
+
280
+ it('deleting a chat hides it and never touches the conversation', async () => {
281
+ model.add(SSH_RT);
282
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions: new Map() });
283
+ const { session } = await model.hook('spark', { label: 'Scratch' });
284
+ const result = model.hideChat(session);
285
+ assert.equal(model.chats().length, 0);
286
+ assert.match(result.note, /remains in Axom's memory/);
287
+ // The row is REMEMBERED as hidden, so rejoining the same session — which
288
+ // the connector's /sessions poll will keep reporting — can't resurrect it.
289
+ await model.hook('spark', { session });
290
+ assert.equal(model.chats().length, 0);
291
+ assert.equal(model.getChat(session).hidden, true);
292
+ });
293
+
294
+ it('names the generation holder only when it is a chat we minted', async () => {
295
+ model.add(SSH_RT);
296
+ const sessions = new Map();
297
+ daemon.axom.endpoints.set('spark', { status: 'connected', sessions });
298
+ const { session } = await model.hook('spark', { label: 'Research' });
299
+ sessions.set(session, { id: session, live: true });
300
+ assert.equal(model.generation('spark').generationHolderLabel, 'Research');
301
+ // A session opened elsewhere (REPL, another client) gets no invented name.
302
+ sessions.clear();
303
+ sessions.set('s-foreign', { id: 's-foreign', live: true });
304
+ assert.equal(model.generation('spark').generationHolder, 's-foreign');
305
+ assert.equal(model.generation('spark').generationHolderLabel, null);
306
+ });
307
+
139
308
  it('keeps the connector in sync with the runtime list', () => {
140
309
  model.add(SSH_RT);
141
310
  model.remove('spark');