samagotchi 0.102.0 → 0.103.0

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -1
@@ -5,10 +5,19 @@
5
5
  // and the step tags' jump to a call. The rail is working_set_rail.js's; the
6
6
  // rules are working_set.js's.
7
7
  //
8
- // Steering (P3a: drop / undrop): the list's rows and groups post
9
- // `/working-set drop|undrop KEY…` lines (+sendLine+, the session command
10
- // route). Between turns one runs at once: its command_ran re-reads the
11
- // conversation and the re-emitted set strikes the item `dropped`. While a
8
+ // Steering (drop / undrop, pin / unpin, + add): the list's rows and groups post
9
+ // `/working-set drop|undrop|pin|unpin|add KEY…` lines (+sendLine+, the session
10
+ // command route). The list's foot (the sheet's last row) adds what the user
11
+ // types (a path, memory:NAME or a URL); a stale or forgotten row offers
12
+ // refresh (an add of it). An add is pending until the next turn sends its
13
+ // note: the server's snapshot lists it (adds) between turns, a queued line
14
+ // mid-turn; an added item the set doesn't hold yet is a `+ name` row with
15
+ // `reads next turn` and an undo (unadd). Between turns one runs at once: its command_ran re-reads the
16
+ // conversation and the re-emitted set strikes the item `dropped` (or marks
17
+ // it pinned). A row offers pin only when the session's strategy has the
18
+ // forget layer (forgetOn: pins guard against the model's forget); a pinned
19
+ // row shows its 📌 and unpin under any strategy, since its drop waits for
20
+ // the unpin. While a
12
21
  // turn runs it is queued: its command_queued (waits: "turn_end"; any
13
22
  // client's, and the snapshot's replay after a reload) makes it pending, a
14
23
  // dashed-warn row with a pill and an undo (the inverse line, queued after
@@ -32,8 +41,9 @@ import { html, nothing, render, svg } from "./vendor/lit-html.js";
32
41
  import { createChipPopover } from "./chip_popover.js";
33
42
  import { showRow } from "./refs.js";
34
43
  import {
35
- BUDGETS, dropRefusal, fold, footprint, footprintText, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText, labelTitle, newerSnapshot,
36
- opsText, overlay, parseSteerLine, pendingSteers, sessionItems, steerId, steerLine, stepRange, stepTag, tokensText,
44
+ addedItems, addLine, BUDGETS, dropRefusal, fold, footprint, footprintText, forgetOn, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText,
45
+ labelTitle, newerSnapshot, opsText, overlay, parseSteerLine, pendingSteers, pinnable, refreshable, serverAddIds, sessionItems, STEER_OPS,
46
+ steerId, steerLine, stepRange, stepTag, tokensText, undroppable,
37
47
  } from "./working_set.js";
38
48
 
39
49
  // The popover's files fold to this many lines (groups open in place, one at
@@ -194,38 +204,69 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
194
204
  if (opening) fetchServer();
195
205
  },
196
206
  onClose: () => { openGroup = null; onChange(); },
207
+ // The foot's + add (the popover's submit): one KEY as typed (quoted
208
+ // when it has spaces).
209
+ onSubmit: (form) => {
210
+ if (!form?.matches?.(".ws-add")) return;
211
+ const input = form.querySelector(".ws-add-in");
212
+ const line = sendLine && addLine(input?.value);
213
+ if (!line) return;
214
+ input.value = "";
215
+ Promise.resolve(sendLine(line)).catch(() => {});
216
+ },
197
217
  });
198
218
 
199
- const pending = () => pendingSteers([...queued.values()]);
219
+ // The pending ops: the server's adds (made between turns), then the
220
+ // queued lines.
221
+ const pending = () => pendingSteers([...queued.values()], state().dir, serverAddIds(server));
222
+ // The pending adds the set doesn't hold yet, as rows.
223
+ const adding = (pend = pending()) => addedItems(pend, sessionSet().items, server, state().dir);
200
224
 
201
225
  // A steer button: +op+ on +items+ ("undo" sends the inverse of a pending op).
202
226
  function actButton(op, items, { label = op, title = "", disabled = false } = {}) {
203
- const ids = items.map(steerId).join(" ");
227
+ // JSON: an id may hold spaces (a file under "my notes/").
228
+ const ids = JSON.stringify(items.map(steerId));
204
229
  return html`<button type="button" class=${`ws-act-b ${op}`} data-ws="steer" data-op=${op} data-ids=${ids} title=${title} ?disabled=${disabled}>${label}</button>`;
205
230
  }
206
231
 
232
+ // A pending op's pill.
233
+ const PILLS = { drop: "drops next turn", undrop: "back next turn", pin: "applies next turn", unpin: "applies next turn",
234
+ add: "reads next turn" };
235
+
207
236
  // What a row offers: a pending op's pill and undo, an undrop, a drop (or
208
- // why not); nothing for an item no op applies to.
209
- function rowActions(item, pend) {
237
+ // why not), a pin or an unpin; nothing for an item no op applies to.
238
+ function rowActions(item, pend, forget) {
210
239
  if (!sendLine) return { pill: nothing, buttons: nothing };
211
- const op = pend.get(steerId(item));
240
+ let op = pend.get(steerId(item));
241
+ // A drop queued for a pinned item: the worker refuses it (unpin it
242
+ // first), so it isn't pending; the row offers its greyed drop.
243
+ if (op === "drop" && item.pinned) op = undefined;
212
244
  if (op) {
213
- const pill = html`<span class="ws-pend" title="applies at the next turn's start">${op === "drop" ? "drops next turn" : "back next turn"}</span>`;
245
+ const when = op === "add" ? "the next turn's note asks the model to read it first" : "applies at the next turn's start";
246
+ const pill = html`<span class="ws-pend" title=${when}>${PILLS[op]}</span>`;
214
247
  return { pill, buttons: actButton(INVERSE[op], [item], { label: "undo", title: `take back the queued ${op}` }) };
215
248
  }
216
- if (item.state === "dropped") {
217
- return { pill: nothing, buttons: actButton("undrop", [item], { title: "send it whole again from the next request (a cache break)" }) };
218
- }
249
+ const undrop = actButton("undrop", [item], { title: "send it whole again from the next request (a cache break)" });
250
+ // Dropped, or a drop on some reads with nothing else of it sent (the
251
+ // model's layers stubbed the rest): undrop is all it offers.
252
+ if (item.state === "dropped" || (undroppable(item) && item.state === "forgotten")) return { pill: nothing, buttons: undrop };
253
+ const buttons = [];
219
254
  const why = dropRefusal(item);
220
- if (why === null) return { pill: nothing, buttons: nothing };
221
- return { pill: nothing, buttons: actButton("drop", [item], { title: why || "send a stub instead of its reads from the next turn's start", disabled: !!why }) };
255
+ if (why !== null) buttons.push(actButton("drop", [item], { title: why || "send a stub instead of its reads from the next turn's start", disabled: !!why }));
256
+ if (item.pinned) buttons.push(actButton("unpin", [item], { title: "the model's forget_outputs may forget it again" }));
257
+ else if (forget && pinnable(item)) buttons.push(actButton("pin", [item], { title: "keep it from the model's forget_outputs (its later reads too)" }));
258
+ // A drop on some of its reads, others still sent: undrop takes it back.
259
+ if (undroppable(item)) buttons.push(undrop);
260
+ if (refreshable(item)) buttons.push(actButton("add", [item], { label: "refresh", title: "the next turn's note asks the model to read it again" }));
261
+ return { pill: nothing, buttons: buttons.length ? buttons : nothing };
222
262
  }
223
263
 
224
- function rowTemplate(item, { session: inSession, group = null, now, pend = new Map() }) {
264
+ function rowTemplate(item, { session: inSession, group = null, now, pend = new Map(), forget = false }) {
225
265
  const fresh = isFresh(seenAt(item), now);
226
266
  const tag = stepTag(item, { session: inSession });
227
- const words = `${opsText(item)}${fresh ? " · just now" : ""}`;
228
- const glyph = item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
267
+ // An added row's words: what it is (a phone's tap on them opens its strip).
268
+ const words = item.added ? "added" : `${opsText(item)}${fresh ? " · just now" : ""}`;
269
+ const glyph = item.added ? "+" : item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
229
270
  const name = memberName(item, group);
230
271
  const full = item.kind === "file" ? item.path || item.key : item.kind === "url" ? item.url || item.key : item.key;
231
272
  let nameEl;
@@ -248,11 +289,12 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
248
289
  const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
249
290
  const tokens = tokensText(item.tokens);
250
291
  const id = steerId(item);
251
- const { pill, buttons } = rowActions(item, pend);
292
+ const { pill, buttons } = rowActions(item, pend, forget);
293
+ const pin = item.pinned ? html`<span class="ws-pin" title="pinned: the model's forget_outputs refuses it">📌</span>` : nothing;
252
294
  const isPending = pill !== nothing;
253
295
  const strip = stripOpen === id && buttons !== nothing;
254
296
  const open = item.kind === "file" ? html`<button type="button" class="ws-act-b open ref" data-ref-kind="file" data-ref-path=${item.ref?.path || full}>open</button>` : nothing;
255
- const row = html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
297
+ const row = html`<div class=${`ws-row ${item.kind}${item.added ? " added" : ""}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${pin}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
256
298
  // One template either way, so the tapped row stays the same node (a
257
299
  // click outside the popover closes it).
258
300
  return html`${row}${strip ? html`<div class="ws-strip">${buttons}${open}</div>` : nothing}`;
@@ -282,7 +324,8 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
282
324
  const memories = set.items.filter((i) => i.kind === "memory");
283
325
  const urls = set.items.filter((i) => i.kind === "url");
284
326
  const pend = pending();
285
- const opts = { session: inSession, now, pend };
327
+ const forget = forgetOn(st.llmContext);
328
+ const opts = { session: inSession, now, pend, forget };
286
329
  const fileLabels = fold(files, POP_BUDGET).labels;
287
330
  // Memories are rows of their own; past the budget one group that opens.
288
331
  const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
@@ -290,14 +333,17 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
290
333
  const memMeta = [`${memories.length - preloaded} used`, preloaded ? `${preloaded} preloaded` : "", set.muted.length ? `${set.muted.length} muted` : ""].filter(Boolean).join(" · ");
291
334
  const turnLabel = view.turn !== null && view.turn >= 0 ? `turn ${view.turn + 1}` : "this turn";
292
335
  const pct = Number.isFinite(st.ctxPct) ? ` · ctx ${Math.round(st.ctxPct)} %` : "";
293
- const empty = !set.items.length && !set.muted.length;
336
+ const added = sendLine ? adding(pend) : [];
337
+ const empty = !set.items.length && !set.muted.length && !added.length;
294
338
  const tpl = html`<div class="ctx-pop-head"><strong>working set</strong><span class="ws-scope" role="group" aria-label="Scope"><button type="button" class=${!inSession ? "on" : ""} aria-pressed=${String(!inSession)} data-ws="scope" data-scope="turn" ?disabled=${view.turn === null || view.turn < 0}>${turnLabel}</button><button type="button" class=${inSession ? "on" : ""} aria-pressed=${String(inSession)} data-ws="scope" data-scope="session">session</button></span><span class="ctx-pop-kind">${set.items.length} item${set.items.length === 1 ? "" : "s"}${pct}</span>${ringTemplate(st.ctxPct)}<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
295
339
  ${empty ? html`<div class="ctx-pop-meta">Nothing yet: the files, memories and URLs ${inSession ? "this session's turns read" : "this turn reads"} show here.</div>` : nothing}
296
340
  ${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
297
341
  ${memories.length || set.muted.length ? html`<div class="ctx-pop-meta">memories${memMeta ? ` · ${memMeta}` : ""}</div>${labelsTemplate(memoryLabels, opts)}${set.muted.map((m) => rowTemplate(m, opts))}` : nothing}
298
342
  ${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
299
- ${pend.size ? html`<div class="ws-pending" role="status">${pend.size} change${pend.size === 1 ? "" : "s"} pending · apply at the next turn's start · <button type="button" class="ws-act-b" data-ws="undo-all">undo all</button></div>` : nothing}
300
- <div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call.${sendLine ? " Drop sends stubs instead of an item's reads from the next turn." : ""} Esc closes.</div>`;
343
+ ${added.length ? html`<div class="ctx-pop-meta">adding · ${added.length}</div>${added.map((it) => rowTemplate(it, opts))}` : nothing}
344
+ ${pend.size ? html`<div class="ws-pending" role="status"><span class="ws-pending-t">${pend.size} change${pend.size === 1 ? "" : "s"} pending<span class="ws-pending-when"> · apply at the next turn's start</span></span><button type="button" class="ws-act-b" data-ws="undo-all">undo all</button></div>` : nothing}
345
+ ${sendLine ? html`<form class="ws-add" data-ws="add-form"><input class="ws-add-in" type="text" name="key" placeholder="+ path, memory:NAME or URL" aria-label="Add to the working set: a path, memory:NAME or a URL" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="go"><button type="submit" class="ws-act-b">add</button></form>` : nothing}
346
+ <div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call.${sendLine ? " Drop sends stubs instead of an item's reads from the next turn; add asks the model to read one first." : ""}${sendLine && forget ? " Pin keeps an item from the model's forget." : ""} Esc closes.</div>`;
301
347
  render(tpl, popover.pop);
302
348
  }
303
349
 
@@ -329,10 +375,10 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
329
375
  // Posts +op+ for the ids (the items' steer words, from the set).
330
376
  function steer(op, ids) {
331
377
  if (!sendLine || !ids.length) return;
332
- const all = [...sessionSet().items, ...turnItems(view.turn)];
378
+ const all = [...sessionSet().items, ...turnItems(view.turn), ...adding()];
333
379
  const byId = new Map(all.map((it) => [steerId(it), it]));
334
380
  const items = ids.map((id) => byId.get(id)).filter(Boolean);
335
- if (items.length) Promise.resolve(sendLine(steerLine(op, items))).catch(() => {});
381
+ if (items.length) Promise.resolve(sendLine(steerLine(op, items, state().dir))).catch(() => {});
336
382
  }
337
383
 
338
384
  popover.pop.addEventListener("click", (e) => {
@@ -349,10 +395,10 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
349
395
  }
350
396
  if (!act) return;
351
397
  if (act.dataset.ws === "steer") {
352
- steer(act.dataset.op, act.dataset.ids.split(" ").filter(Boolean));
398
+ steer(act.dataset.op, JSON.parse(act.dataset.ids || "[]"));
353
399
  } else if (act.dataset.ws === "undo-all") {
354
400
  const pend = pending();
355
- for (const op of ["drop", "undrop"]) {
401
+ for (const op of STEER_OPS) {
356
402
  const ids = [...pend].filter(([, o]) => o === op).map(([id]) => id);
357
403
  steer(INVERSE[op], ids);
358
404
  }
@@ -398,6 +444,12 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
398
444
  if (popover.isOpen()) popover.refresh();
399
445
  }
400
446
 
447
+ // The pending ops changed: the list and the rail's marks.
448
+ function pendingChanged() {
449
+ changed();
450
+ for (const fn of listeners) fn();
451
+ }
452
+
401
453
  // The footprints on the page (the history's, the hand-off's) drawn again
402
454
  // in place when their items' states changed: the same node (its focus,
403
455
  // the popover's opener), its children and label replaced.
@@ -466,12 +518,18 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
466
518
  refreshFootprints();
467
519
  fetchServer();
468
520
  },
521
+ // The session's strategy changed (/llm-context's command_ran): the open
522
+ // list redraws, its pin buttons shown only under forget (forgetOn).
523
+ strategyChanged() {
524
+ if (popover.isOpen()) popover.refresh();
525
+ },
469
526
  // A :working_set_updated event's set ({items, at}).
470
527
  update(snapshot) {
471
528
  events += 1;
472
529
  takeServer(snapshot, { event: true });
473
530
  },
474
- // +fn()+ runs after each new server snapshot (the rail's redraw).
531
+ // +fn()+ runs after each new server snapshot and each change of the
532
+ // pending ops (the rail's redraw).
475
533
  onServer(fn) { listeners.add(fn); },
476
534
  // A command queued for after the running turn (its command_queued,
477
535
  // waits "turn_end"): a steer line is pending until its command_ran.
@@ -482,11 +540,11 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
482
540
  }
483
541
  if (event?.waits !== "turn_end" || !event.command_id || !parseSteerLine(event.line)) return;
484
542
  queued.set(event.command_id, event.line);
485
- changed();
543
+ pendingChanged();
486
544
  },
487
545
  // A command ran (or was dropped): its line is no longer pending.
488
546
  commandRan(event) {
489
- if (event?.command_id && queued.delete(event.command_id)) changed();
547
+ if (event?.command_id && queued.delete(event.command_id)) pendingChanged();
490
548
  },
491
549
  // The steer lines pending (for the specs and the rail).
492
550
  pending: () => pending(),
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../session"
4
+ require_relative "../session_access/local"
5
+ require_relative "../session_inbox"
6
+ require_relative "../reply_wait"
7
+ require_relative "../pending_question"
8
+ require_relative "../context_sources"
9
+ require_relative "../context_command"
10
+ require_relative "../send_command"
11
+ require_relative "../launch_mode"
12
+ require_relative "../model_profile"
13
+
14
+ module Samagotchi
15
+ module Web
16
+ # The routes chi's session commands need from another machine's chi web
17
+ # (chi --remote NAME …, SessionAccess::Remote), one per
18
+ # SessionAccess::Local operation that the page's own routes don't cover:
19
+ # a prefix resolved, the CLI's list rows, a wait's pre-send state and
20
+ # the wait itself, a note, a push into a context source. Each calls
21
+ # SessionAccess::Local, so a Remote maps 1:1 onto them. Included in
22
+ # App: its ROUTES name the handlers, and its helpers (json_response,
23
+ # parse_json, …) answer. See docs/architecture.md (chi web's routes for
24
+ # the session commands).
25
+ module SessionAccessRoutes
26
+ # A wait route answers within this many seconds (status timeout); the
27
+ # client asks again until its own --timeout.
28
+ WAIT_MAX_SECONDS = 30.0
29
+ # GET /api/info's feature: this chi web has these routes (a remote
30
+ # client checks it before its first session command).
31
+ FEATURE = "session_commands"
32
+ # GET /api/info's feature: this chi web has the routes a remote
33
+ # attached TUI needs (wake, worker; chi --remote NAME --attach).
34
+ ATTACH_FEATURE = "remote_attach"
35
+ # The longest POST …/wake waits for a worker's Bridge
36
+ # (TerminalUI::AttachLauncher::BRIDGE_WAIT, an attach's here).
37
+ WAKE_MAX_WAIT = 10.0
38
+
39
+ private
40
+
41
+ # This server's sessions, as a session command reaches them here.
42
+ def session_access = @session_access ||= SessionAccess::Local.new(state_dir: @state_dir)
43
+
44
+ # A refusal an exception carries: its stable +code+, +detail+ (what
45
+ # the page shows; the message by default), and for a remote client
46
+ # the exception's own message and the fields its class is built from
47
+ # (session_id, reason, …), so it can raise the same class.
48
+ def refusal_response(status, code, error, detail: error.message, **fields)
49
+ json_response(status, { error: code, detail: detail, message: error.message, **fields }.compact)
50
+ end
51
+
52
+ # The body as a Hash ({} when there is none), or nil when it isn't one.
53
+ def body_hash(req)
54
+ raw = request_body(req)
55
+ return {} if raw.to_s.strip.empty?
56
+
57
+ parsed = parse_json(raw)
58
+ parsed.is_a?(Hash) ? parsed : nil
59
+ end
60
+
61
+ def truthy_param?(value) = %w[1 true yes].include?(value.to_s.strip.downcase)
62
+
63
+ # GET /api/sessions/resolve?id=PREFIX: the full id of the session an id
64
+ # or a unique prefix names (SessionAccess::Local#resolve). 200 {id};
65
+ # 404 not_found; 400 ambiguous_id with ids (the sessions it names).
66
+ def handle_resolve(req)
67
+ given = req.params["id"].to_s
68
+ return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
69
+
70
+ json_response(200, { id: session_access.resolve(given) })
71
+ rescue Session::AmbiguousId => e
72
+ refusal_response(400, "ambiguous_id", e, ids: e.ids)
73
+ rescue ArgumentError
74
+ error_response(404, "not_found", "no session #{given[0, 80]}")
75
+ end
76
+
77
+ # GET /api/sessions/:id?brief=1: the session as a session command
78
+ # reads it (SessionAccess::Local#show): the file's status (not the
79
+ # displayed one), its names, folder, model and parent_answers, its
80
+ # owner's kind, and the file's pending question, never its messages.
81
+ # A chi web from before ?brief answers the whole read, which has the
82
+ # session part too.
83
+ def handle_brief_read(id)
84
+ session = @session_class.load(id, state_dir: default_state_dir)
85
+ owner = session_access.owner(id)
86
+ json_response(200, {
87
+ brief: true,
88
+ session: { id: session.id, status: session.status, title: session.title,
89
+ display_title: session.display_title, project_root: session.project_root,
90
+ working_directory: session.working_directory, model_name: session.model_name,
91
+ model_typed: session.model_typed, parent_answers: session.parent_answers,
92
+ owner: owner&.kind },
93
+ pending_question: session.pending_question&.to_file
94
+ })
95
+ end
96
+
97
+ # GET /api/sessions/summaries: the rows chi sessions list prints
98
+ # (SessionManager.session_summaries: status, live, busy, owner, recap,
99
+ # ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
100
+ # folder: its project's sessions; none: every project's), cwd (in that
101
+ # folder or below), live=1, archived=1, tests=0 (leave test runs out),
102
+ # sort, order, limit. An array, newest first by default.
103
+ def handle_summaries(req)
104
+ dir, error = scope_dir(req.params["dir"])
105
+ return error if error
106
+
107
+ cwd = req.params["cwd"].to_s
108
+ cwd = nil if cwd.strip.empty?
109
+ return error_response(400, "invalid_cwd", "cwd is an absolute path: #{cwd[0, 200]}") if cwd && !cwd.start_with?("/")
110
+
111
+ rows = session_access.summaries(
112
+ live: truthy_param?(req.params["live"]), cwd: cwd, limit: sanitize_limit(req.params["limit"]),
113
+ include_tests: !%w[0 false no].include?(req.params["tests"].to_s.strip.downcase),
114
+ project_root: dir && ProjectScope.root_for(dir), include_archived: truthy_param?(req.params["archived"]),
115
+ sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"])
116
+ )
117
+ json_response(200, rows)
118
+ end
119
+
120
+ # POST /api/sessions/:id/wait/prepare: what chi send --wait and chi
121
+ # answer read before anything is sent (SessionAccess::Local#
122
+ # prepare_wait). Body (all optional): question_id (the question the
123
+ # baseline counts as reported; absent: the one pending now, null:
124
+ # none), turn (--turn: known_turn says whether it can be waited for),
125
+ # created (a session just created: no reply, no question), busy (chi
126
+ # send --wait with a message: refuse a running turn, 409 busy or
127
+ # waiting in SendCommand::Busy's words, with its fields: question,
128
+ # parent_strategy; queue: true, --queue, refuses only a waiting
129
+ # question). 200 {session_id, cursor, baseline:
130
+ # {messages, question_id, last_turn}, own_turn, known_turn, running,
131
+ # live, settled, last_turn_id}.
132
+ def handle_wait_prepare(req, id)
133
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
134
+ turn = body["turn"]
135
+ return error_response(400, "invalid_turn", "turn is a turn id") unless turn.nil? || turn.is_a?(String)
136
+
137
+ session = session_access.show(id)
138
+ if body["busy"] == true
139
+ owner = session_access.owner(id) if session.status == Session::STATUS_RUNNING
140
+ busy = SendCommand::Busy.for(session, owner: owner, queue: body["queue"] == true)
141
+ return json_response(409, busy_json(busy)) if busy
142
+ end
143
+
144
+ question_id = body.key?("question_id") ? body["question_id"]&.to_s : :pending
145
+ start = session_access.prepare_wait(id, session: session, question_id: question_id, turn: turn,
146
+ created: body["created"] == true)
147
+ json_response(200, wait_start_json(id, start))
148
+ end
149
+
150
+ # A Busy refusal: its words here, and its fields, which a chi --remote
151
+ # client words with its own commands (SessionAccess::Remote#busy).
152
+ def busy_json(busy)
153
+ { error: busy.error, detail: busy.message, message: busy.message, session_id: busy.session_id,
154
+ question: busy.question&.to_file, parent_strategy: (strategy_json(busy.strategy) if busy.strategy) }.compact
155
+ end
156
+
157
+ def strategy_json(strategy) = { name: strategy.name, allowed: strategy.allowed, origin: strategy.origin.to_s }
158
+
159
+ def wait_start_json(id, start)
160
+ { session_id: id, cursor: start.cursor, baseline: start.baseline.to_h, own_turn: start.own_turn,
161
+ known_turn: start.known_turn, running: start.running, live: start.live, settled: start.settled,
162
+ last_turn_id: start.last_turn_id }
163
+ end
164
+
165
+ # POST /api/sessions/:id/wait: ReplyWait for at most WAIT_MAX_SECONDS
166
+ # (SessionAccess::Local#wait_reply). Body: cursor, baseline
167
+ # ({messages, question_id, last_turn}, as the prepare route gave it),
168
+ # turn, queued, ended_before, owner_grace (seconds, null: wait for
169
+ # whatever wakes a worker), timeout (seconds, capped). 200: the
170
+ # ReplyWait::Result's fields (status: done, waiting_for_answer,
171
+ # no_reply, error, stopped, timeout or worker_gone; text, file,
172
+ # question, outcome, …; never the Session) and parent_strategy, what
173
+ # a parent may pick on the session's questions here
174
+ # (ParentReport.strategy_for). A timeout status: ask again. 503
175
+ # server_stopping when chi web stops mid-wait.
176
+ def handle_wait(req, id)
177
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
178
+ options, error = wait_options(body)
179
+ return error if error
180
+
181
+ session_access.show(id)
182
+ result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
183
+ cancelled: -> { !@server_running.call })
184
+ return error_response(503, "server_stopping", "chi web is stopping; wait again") if result.status == :canceled
185
+
186
+ json_response(200, wait_result_json(result, session_access.parent_strategy(id)))
187
+ end
188
+
189
+ # The wait's ReplyWait arguments from the body: [options, nil] or
190
+ # [nil, a 400 response].
191
+ def wait_options(body)
192
+ strings = %w[cursor turn].to_h { |key| [key, body[key]] }
193
+ if strings.any? { |_, value| !value.nil? && !value.is_a?(String) }
194
+ return [nil, error_response(400, "invalid_wait", "cursor and turn are strings")]
195
+ end
196
+
197
+ baseline, error = wait_baseline(body["baseline"])
198
+ return [nil, error] if error
199
+
200
+ numbers = %w[timeout owner_grace].to_h { |key| [key, body[key]] }
201
+ if numbers.any? { |_, value| !value.nil? && !(value.is_a?(Numeric) && value.positive?) }
202
+ return [nil, error_response(400, "invalid_wait", "timeout and owner_grace are positive seconds")]
203
+ end
204
+
205
+ timeout = [numbers["timeout"] || WAIT_MAX_SECONDS, WAIT_MAX_SECONDS].min
206
+ [{ cursor: strings["cursor"], turn: strings["turn"], baseline: baseline, timeout: timeout,
207
+ owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true }, nil]
208
+ end
209
+
210
+ def wait_baseline(raw)
211
+ return [nil, nil] if raw.nil?
212
+
213
+ fields = { "messages" => Integer, "question_id" => String, "last_turn" => String }
214
+ unless raw.is_a?(Hash) && raw.all? { |key, value| fields.key?(key) && (value.nil? || value.is_a?(fields[key])) }
215
+ return [nil, error_response(400, "invalid_wait", "baseline is {messages, question_id, last_turn}")]
216
+ end
217
+
218
+ [ReplyWait::Baseline.new(**raw.transform_keys(&:to_sym)), nil]
219
+ end
220
+
221
+ RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
222
+
223
+ def wait_result_json(result, strategy)
224
+ json = { status: result.status.to_s }
225
+ RESULT_FIELDS.each { |key| json[key] = result[key] }
226
+ json[:text] = json[:text]&.dup&.force_encoding(Encoding::UTF_8)&.scrub
227
+ json[:question] = result.question&.to_file
228
+ json[:parent_strategy] = strategy_json(strategy)
229
+ json
230
+ end
231
+
232
+ # POST /api/sessions/:id/wake {wait}: the session's worker for a remote
233
+ # attach (SessionAccess::Local#wake): the live one, else resumed and
234
+ # waited for up to +wait+ seconds (WAKE_MAX_WAIT, also the most). 200
235
+ # {session_id, woke, worker: {epoch, started_at, version, features},
236
+ # model_name, default_model (this machine's config, for the TUI's
237
+ # status row), parent_strategy, installed (the newest chi here)}; 409
238
+ # owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
239
+ # session, as chi --attach here says); 504 worker_timeout (its Bridge
240
+ # didn't come in time); 404 not_found.
241
+ def handle_wake(req, id)
242
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
243
+ wait = body.fetch("wait", WAKE_MAX_WAIT)
244
+ return error_response(400, "invalid_wait", "wait is positive seconds") unless wait.is_a?(Numeric) && wait.positive?
245
+
246
+ woken = session_access.wake(id, wait: [wait, WAKE_MAX_WAIT].min)
247
+ unless woken.sidecar
248
+ late = format(LaunchMode::BRIDGE_LATE, id)
249
+ return json_response(504, { error: "worker_timeout", detail: late, message: late, session_id: id })
250
+ end
251
+
252
+ json_response(200, { session_id: id, woke: woken.woke, worker: worker_json(woken.sidecar),
253
+ model_name: session_access.show(id).model_name,
254
+ default_model: ModelProfile.default_model_name,
255
+ parent_strategy: strategy_json(session_access.parent_strategy(id)),
256
+ installed: @hub&.installed })
257
+ rescue SessionManager::OwnedByTUI => e
258
+ owned_by_tui(e)
259
+ rescue SessionAccess::Refused => e
260
+ refusal_response(409, e.code, e, session_id: id)
261
+ end
262
+
263
+ # GET /api/sessions/:id/worker: the session's worker now, never
264
+ # started (SessionAccess::Local#worker): a remote attach's
265
+ # rediscovery and its features after a restart. 200 {session_id,
266
+ # live, stopped, owner: worker|tui|null, worker: {epoch, started_at,
267
+ # version, features}|null, parent_strategy}; 404 not_found.
268
+ def handle_worker(_req, id)
269
+ state = session_access.worker(id)
270
+ json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
271
+ worker: state.sidecar && worker_json(state.sidecar),
272
+ parent_strategy: strategy_json(session_access.parent_strategy(id)) })
273
+ end
274
+
275
+ # A live worker as a remote client knows it: its epoch is its
276
+ # identity (the one in its event ids).
277
+ def worker_json(sidecar)
278
+ { epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
279
+ end
280
+
281
+ # POST /api/sessions/:id/notes {text, source}: a context note for the
282
+ # session (NoteDelivery, as chi note; source: where it came from,
283
+ # "cli" by default, never "broadcast"). 200 {status: "queued" (a
284
+ # worker adds it) or "waits" (the next start does), session_id,
285
+ # queued}; 409 owned_by_tui (status refused: a chi REPL has it); 400
286
+ # note_rejected (empty, or over 16 KiB).
287
+ def handle_notes(req, id)
288
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
289
+ text = body["text"]
290
+ source = body.fetch("source", "cli")
291
+ return error_response(400, "missing_fields", "text is required") unless text.is_a?(String)
292
+ return error_response(400, "invalid_source", "source is a word") unless source.is_a?(String) && !source.strip.empty?
293
+ return error_response(400, "invalid_source", "source broadcast is chi broadcast's own") if source.strip == "broadcast"
294
+
295
+ session_access.show(id)
296
+ result = session_access.note(id, text: text, source: source.strip)
297
+ if result.status == :refused
298
+ error = SessionManager::OwnedByTUI.new(id)
299
+ return refusal_response(409, "owned_by_tui", error, session_id: id, status: "refused", queued: 0)
300
+ end
301
+
302
+ json_response(200, { status: result.status.to_s, session_id: id, queued: result.queued })
303
+ rescue SessionInbox::NoteRejected => e
304
+ error_response(400, "note_rejected", e.message)
305
+ end
306
+
307
+ # POST /api/sessions/:id/context/:name/push {text, summary, wake,
308
+ # hint}: new text for the session's --push source (chi context push;
309
+ # the fields as a source's JSON output gives them, checked the same
310
+ # way). 200 {status: "pushed", name, before, revision, changed}; 404
311
+ # not_found (no such source); 409 not_push (a command source); 400
312
+ # invalid_text.
313
+ def handle_context_push(req, id, name)
314
+ body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
315
+ return error_response(400, "missing_fields", "text is required") unless body["text"].is_a?(String)
316
+
317
+ fetched = begin
318
+ ContextSources.parse_output(JSON.generate(body.slice("text", "summary", "wake", "hint")))
319
+ rescue ContextSources::Invalid => e
320
+ return error_response(400, "invalid_text", e.message)
321
+ end
322
+ pushed = session_access.context_push(context_target(id), name, fetched)
323
+ case pushed.status
324
+ when :missing then error_response(404, "not_found", "no source #{name[0, 40]}")
325
+ when :not_push then error_response(409, "not_push", SessionAccess::ContextPush.not_push_message(name))
326
+ else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
327
+ changed: pushed.before != pushed.revision })
328
+ end
329
+ rescue ContextSources::Invalid
330
+ error_response(404, "not_found", "no source #{name[0, 40]}")
331
+ end
332
+
333
+ # The session as chi context's Target (SessionAccess::Local's context
334
+ # calls take one).
335
+ # @raise [ArgumentError] no such session
336
+ def context_target(id)
337
+ session = session_access.show(id)
338
+ ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
339
+ label: id[0, 8])
340
+ end
341
+ end
342
+ end
343
+ end
@@ -10,10 +10,14 @@ module Samagotchi
10
10
  # Bridge (port, bind), when it started (a new worker behind the same
11
11
  # "worker" owner), the chi it runs (chi update, the web's stale-worker
12
12
  # badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
13
- # what else it can do (Bridge::FEATURES). The Bridge writes it on start
13
+ # what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
14
+ # one worker identity: the epoch in every event id, so a remote client
15
+ # tells a restarted worker apart; nil from a worker before it). The
16
+ # Bridge writes it on start
14
17
  # and removes it on stop; one a dead worker left stays until a client's
15
18
  # probe finds its port closed (.live with unlink: true).
16
- class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features)
19
+ class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features,
20
+ :epoch)
17
21
  FILE = "bridge.json"
18
22
  # Seconds a liveness probe waits for the port to take a connect.
19
23
  PROBE_TIMEOUT = 0.2
@@ -29,7 +33,7 @@ module Samagotchi
29
33
  port = data["port"]
30
34
  new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
31
35
  started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
32
- features: Array(data["features"]).map(&:to_s))
36
+ features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s)
33
37
  rescue JSON::ParserError, SystemCallError
34
38
  nil
35
39
  end
@@ -63,13 +67,16 @@ module Samagotchi
63
67
  # @return [Integer, nil] .live's port
64
68
  def self.live_port(session_dir, unlink:, host: "127.0.0.1") = live(session_dir, unlink: unlink, host: host)&.port
65
69
 
66
- def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: []) = super
70
+ def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
71
+ epoch: nil) = super
67
72
 
68
- # Written whole or not at all; input_format only when it names one.
73
+ # Written whole or not at all; input_format and epoch only when they
74
+ # name one.
69
75
  def write(session_dir)
70
76
  FileUtils.mkdir_p(session_dir)
71
77
  record = to_h.transform_keys(&:to_s)
72
78
  record.delete("input_format") if input_format.nil?
79
+ record.delete("epoch") if epoch.nil?
73
80
  AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n")
74
81
  end
75
82
  end