samagotchi 0.65.0 → 0.67.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 (155) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +2 -1
  4. data/bin/chi +51 -2
  5. data/docs/architecture.md +50 -28
  6. data/docs/cli.md +14 -2
  7. data/docs/configuration.md +101 -16
  8. data/docs/context.md +39 -2
  9. data/docs/desktop.md +90 -22
  10. data/docs/guardrails.md +8 -5
  11. data/docs/hooks.md +2 -2
  12. data/docs/internals/anthropic.md +156 -0
  13. data/docs/internals/background-tasks.md +1 -1
  14. data/docs/internals/bundles.md +2 -0
  15. data/docs/internals/engine.md +75 -30
  16. data/docs/internals/gemma4-contract.md +1 -1
  17. data/docs/internals/llm-context-bench.md +5 -4
  18. data/docs/internals/prompt-caching.md +36 -13
  19. data/docs/memory.md +1 -1
  20. data/docs/plugins.md +289 -25
  21. data/docs/releasing.md +1 -1
  22. data/docs/sessions.md +16 -6
  23. data/docs/sub-agent.md +6 -1
  24. data/docs/testing.md +17 -3
  25. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  26. data/lib/samagotchi/bootstrap_command.rb +13 -3
  27. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  28. data/lib/samagotchi/bridge.rb +6 -3
  29. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/claude-peer/plugin.rb +40 -302
  31. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  33. data/lib/samagotchi/bundles/codex-peer/manifest.yml +22 -0
  34. data/lib/samagotchi/bundles/codex-peer/plugin.rb +189 -0
  35. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +136 -0
  36. data/lib/samagotchi/bundles/codex-peer/scripts/pair +92 -0
  37. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +76 -0
  38. data/lib/samagotchi/bundles/coordinator/manifest.yml +5 -5
  39. data/lib/samagotchi/bundles/coordinator/plugin.rb +24 -2
  40. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  44. data/lib/samagotchi/bundles/system/delegated.md +2 -1
  45. data/lib/samagotchi/bundles/system/manifest.yml +5 -5
  46. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  47. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  48. data/lib/samagotchi/cache_confirm.rb +9 -6
  49. data/lib/samagotchi/commands/registry.rb +25 -8
  50. data/lib/samagotchi/config.rb +23 -8
  51. data/lib/samagotchi/context_absorber.rb +66 -13
  52. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  53. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  54. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  55. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  56. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  57. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  58. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  59. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  60. data/lib/samagotchi/desktop/macos.rb +84 -13
  61. data/lib/samagotchi/desktop_command.rb +14 -3
  62. data/lib/samagotchi/engine.rb +120 -107
  63. data/lib/samagotchi/engine_peers.rb +3 -0
  64. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  65. data/lib/samagotchi/guardrails/approval.rb +1 -1
  66. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  67. data/lib/samagotchi/guardrails/rules.rb +1 -1
  68. data/lib/samagotchi/guardrails/targets.rb +1 -1
  69. data/lib/samagotchi/hook_routes.rb +151 -0
  70. data/lib/samagotchi/hooks/registry.rb +2 -2
  71. data/lib/samagotchi/host_keys.rb +147 -0
  72. data/lib/samagotchi/kernel_loop.rb +9 -1
  73. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  74. data/lib/samagotchi/llm/anthropic_assembly.rb +4 -2
  75. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  76. data/lib/samagotchi/llm/api_key.rb +23 -7
  77. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  78. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  79. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  80. data/lib/samagotchi/llm_context_override.rb +1 -0
  81. data/lib/samagotchi/peer_sources.rb +58 -0
  82. data/lib/samagotchi/plugin/api.rb +138 -156
  83. data/lib/samagotchi/plugin/context.rb +22 -1
  84. data/lib/samagotchi/plugin/loader.rb +5 -2
  85. data/lib/samagotchi/plugin/peer.rb +394 -0
  86. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  87. data/lib/samagotchi/plugin_tasks.rb +2 -2
  88. data/lib/samagotchi/row_refs.rb +1 -1
  89. data/lib/samagotchi/self_report.rb +5 -3
  90. data/lib/samagotchi/send_command.rb +41 -15
  91. data/lib/samagotchi/session.rb +18 -1
  92. data/lib/samagotchi/session_commands.rb +16 -1
  93. data/lib/samagotchi/session_continue.rb +58 -15
  94. data/lib/samagotchi/session_inbox.rb +0 -13
  95. data/lib/samagotchi/session_manager.rb +4 -1
  96. data/lib/samagotchi/session_setup.rb +14 -7
  97. data/lib/samagotchi/system_prompt.rb +16 -1
  98. data/lib/samagotchi/terminal_ui/attach_launcher.rb +19 -9
  99. data/lib/samagotchi/terminal_ui.rb +18 -2
  100. data/lib/samagotchi/tool_call_parser.rb +1 -1
  101. data/lib/samagotchi/tool_runner.rb +13 -1
  102. data/lib/samagotchi/toolkit.rb +246 -0
  103. data/lib/samagotchi/toolkit_binding.rb +138 -0
  104. data/lib/samagotchi/toolkit_command.rb +117 -0
  105. data/lib/samagotchi/tools/context_read.rb +14 -0
  106. data/lib/samagotchi/tools/execute.rb +12 -12
  107. data/lib/samagotchi/tools/registry.rb +43 -6
  108. data/lib/samagotchi/tools/task_create.rb +6 -6
  109. data/lib/samagotchi/tools/task_get.rb +13 -13
  110. data/lib/samagotchi/tools/task_list.rb +6 -6
  111. data/lib/samagotchi/tools/task_record.rb +52 -0
  112. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  113. data/lib/samagotchi/tools/task_stop.rb +6 -6
  114. data/lib/samagotchi/tools/task_wait.rb +12 -13
  115. data/lib/samagotchi/tools/tool_index.rb +454 -0
  116. data/lib/samagotchi/update_command.rb +9 -6
  117. data/lib/samagotchi/version.rb +1 -1
  118. data/lib/samagotchi/web/app.rb +28 -9
  119. data/lib/samagotchi/web/capabilities.rb +1 -1
  120. data/lib/samagotchi/web/public/activity.js +2 -2
  121. data/lib/samagotchi/web/public/annotate_actions.js +109 -0
  122. data/lib/samagotchi/web/public/annotate_bar.js +84 -2
  123. data/lib/samagotchi/web/public/app.js +52 -43
  124. data/lib/samagotchi/web/public/chains.js +1 -1
  125. data/lib/samagotchi/web/public/commands.js +6 -4
  126. data/lib/samagotchi/web/public/composer_size.js +2 -2
  127. data/lib/samagotchi/web/public/copy.js +1 -1
  128. data/lib/samagotchi/web/public/ctx.js +1 -1
  129. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  130. data/lib/samagotchi/web/public/families_view.js +1 -1
  131. data/lib/samagotchi/web/public/format.js +11 -7
  132. data/lib/samagotchi/web/public/images.js +25 -2
  133. data/lib/samagotchi/web/public/index.html +9 -0
  134. data/lib/samagotchi/web/public/info_bar.js +5 -3
  135. data/lib/samagotchi/web/public/llm_context_chip.js +2 -2
  136. data/lib/samagotchi/web/public/notify.js +6 -4
  137. data/lib/samagotchi/web/public/question_card.js +1 -1
  138. data/lib/samagotchi/web/public/ref_scan.js +1 -1
  139. data/lib/samagotchi/web/public/session_state.js +17 -0
  140. data/lib/samagotchi/web/public/session_title.js +2 -3
  141. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  142. data/lib/samagotchi/web/public/stage_model.js +2 -2
  143. data/lib/samagotchi/web/public/stream_handlers.js +13 -0
  144. data/lib/samagotchi/web/public/strip.js +3 -3
  145. data/lib/samagotchi/web/public/task_stop.js +3 -2
  146. data/lib/samagotchi/web/public/toolkit_chip.js +29 -0
  147. data/lib/samagotchi/web/public/turn_events.js +1 -1
  148. data/lib/samagotchi/web/public/turn_html.js +2 -2
  149. data/lib/samagotchi/web/public/turn_model.js +2 -2
  150. data/lib/samagotchi/web/public/turn_view.js +1 -1
  151. data/lib/samagotchi/web/server.rb +0 -4
  152. data/lib/samagotchi/worker.rb +13 -8
  153. data/lib/samagotchi/worker_inbound.rb +36 -4
  154. data/lib/samagotchi/worker_wakes.rb +67 -7
  155. metadata +23 -1
@@ -67,7 +67,30 @@ export function turnRefs(chips) {
67
67
  }
68
68
 
69
69
  // Chips again for a prompt the worker handed back (its images are stored
70
- // already, so they need no upload).
70
+ // already, so they need no upload: to that session).
71
71
  export function restoredChips(sessionId, images) {
72
- return (images || []).filter((i) => i && i.file).map((ref) => ({ name: ref.name || "image", ref, src: imageUrl(sessionId, ref) }));
72
+ return (images || []).filter((i) => i && i.file)
73
+ .map((ref) => ({ name: ref.name || "image", ref, session: sessionId, src: imageUrl(sessionId, ref) }));
74
+ }
75
+
76
+ // A restored chip has no file of its own: its image, read back from where
77
+ // its session stores it.
78
+ async function fetchChipFile(src, name) {
79
+ const res = await fetch(src);
80
+ if (!res.ok) throw new Error(`Can't read ${name} again (${res.status})`);
81
+ const blob = await res.blob();
82
+ return new File([blob], name, { type: blob.type });
83
+ }
84
+
85
+ // Uploads the chips a turn to +sessionId+ needs: a ref is into one session's
86
+ // image store (chip.session), so a chip uploaded to another one (a failed
87
+ // send there, then a switch) goes up again, to this one. Sets each chip's
88
+ // ref and session in place: a failed send keeps them for another try.
89
+ export async function uploadChips(chips, sessionId, { upload, fetchFile = fetchChipFile }) {
90
+ for (const chip of chips || []) {
91
+ if (chip.ref && chip.session === sessionId) continue;
92
+ const file = chip.file || await fetchFile(chip.src, chip.name);
93
+ chip.ref = await upload(sessionId, file, chip.name);
94
+ chip.session = sessionId;
95
+ }
73
96
  }
@@ -480,6 +480,15 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
480
480
  #annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
481
481
  #annotateBar[hidden]{display:none}
482
482
  #annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px var(--shadow-strong)}
483
+ /* A bundle's pills (chi.annotation) after a thin separator; beyond 3, "⋯" opens the rest in a list under it. */
484
+ #annotateBar .annotate-actions{display:flex;flex-wrap:wrap;gap:4px;align-items:center;position:relative}
485
+ #annotateBar .annotate-actions[hidden]{display:none}
486
+ #annotateBar .annotate-sep{width:1px;align-self:stretch;margin:3px 2px;background:var(--border-strong)}
487
+ #annotateBar .annotate-action,#annotateBar .annotate-more{background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
488
+ #annotateBar .annotate-action:disabled{opacity:.5;cursor:not-allowed}
489
+ #annotateBar .annotate-action[data-sends]::after{content:"↗";margin-left:4px;opacity:.75}
490
+ #annotateBar .annotate-more-list{position:absolute;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;align-items:flex-end;gap:4px}
491
+ #annotateBar .annotate-more-list[hidden]{display:none}
483
492
  #annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
484
493
  /* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
485
494
  .bubble.output,.bubble.user{position:relative}
@@ -1,6 +1,6 @@
1
1
  // The session bar's info line (#infoText): the open session's id, its copy
2
2
  // chi --attach button, status, model, chain links, parent and delegates,
3
- // llm ctx, thinking and reasoning history chips, model notes, memories, folder, ctx, speed,
3
+ // llm ctx, thinking, reasoning history and tools chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
5
  // plain model app.js builds (updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
@@ -16,6 +16,7 @@ import { ALL_SESSIONS_HASH, sessionHash } from "./route.js";
16
16
  import { chipTemplate as llmContextChip } from "./llm_context_chip.js";
17
17
  import { chipTemplate as thinkingChip } from "./thinking_chip.js";
18
18
  import { chipTemplate as reasoningHistoryChip } from "./reasoning_history_chip.js";
19
+ import { chipTemplate as toolkitChip } from "./toolkit_chip.js";
19
20
 
20
21
  export const PREV = "←";
21
22
  export const NEXT = "→";
@@ -62,7 +63,8 @@ function kidsTemplate(kids) {
62
63
  // mismatch}: format.js modelLabel), chain ({prev, next}: chainLinkTemplate's
63
64
  // links or null), parent ({id, title} or null), kids (format.js
64
65
  // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
65
- // summaries; reasoningHistory null on a native host: no chip), notes
66
+ // summaries; reasoningHistory null on a native host: no chip), toolkit
67
+ // (the tools chip's summary, null on full: no chip), notes
66
68
  // ({text, title} or null), memories (the used names), dir, ctx ({text,
67
69
  // tip}), speed ({text, tip} or null), clock (the session time's text, ""
68
70
  // for none), worker ({badge, busy} or null).
@@ -70,7 +72,7 @@ export function infoBarHtml(bar) {
70
72
  if (!bar) return nothing;
71
73
  const attach = `chi --attach ${bar.id}`;
72
74
  const memories = bar.memories || [];
73
- return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
75
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
74
76
  }
75
77
 
76
78
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { html, nothing, render } from "./vendor/lit-html.js";
16
16
 
17
- export const STRATEGIES = [
17
+ const STRATEGIES = [
18
18
  { value: "none", label: "none" },
19
19
  { value: "stale", label: "stale" },
20
20
  { value: "stale,forget", label: "stale + forget (experimental)" },
@@ -144,7 +144,7 @@ export function choiceWords(choice) {
144
144
 
145
145
  // The strategy list, with the session's own value when it isn't one of them
146
146
  // (forget alone, a newer chi's layer).
147
- export function strategyOptions(chosen) {
147
+ function strategyOptions(chosen) {
148
148
  if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
149
149
  return [...STRATEGIES, { value: chosen, label: chosen }];
150
150
  }
@@ -2,6 +2,8 @@
2
2
  // summaries (GET /api/events). The page shows an OS notification and a
3
3
  // title badge for what it returns, while the tab is not in front.
4
4
 
5
+ import { clip } from "./format.js";
6
+
5
7
  // The bell's state. OS notifications need a secure context: on chi web
6
8
  // over http at a LAN address the API is missing or always denied, so the
7
9
  // bell is hidden there (the title badge still counts).
@@ -16,7 +18,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
16
18
  // chi ran for a delegate child's report (last_turn origin delegate_report)
17
19
  // or an attached context change (context): nobody started it, so nobody
18
20
  // watched it.
19
- export const DONE_MIN_SECONDS = 10;
21
+ const DONE_MIN_SECONDS = 10;
20
22
  const UNWATCHED_ORIGINS = ["delegate_report", "context"];
21
23
 
22
24
  // The step-limit question (kind "continue": a turn ran out of iterations
@@ -90,7 +92,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
90
92
  // @param prev the summary held before (null for a session not seen yet)
91
93
  // @param next the new summary (null when the session is gone)
92
94
  // @return [String] keys, maybe none
93
- export function closedKeys(prev, next) {
95
+ function closedKeys(prev, next) {
94
96
  const keys = [];
95
97
  const q = prev?.pending_question;
96
98
  const nq = next?.pending_question;
@@ -147,7 +149,7 @@ export function trackAttention(state, type, data = {}) {
147
149
  // No question or answer text (it would show on a lock screen).
148
150
  export function attentionText(session, attention) {
149
151
  const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
150
- const title = name.length > 60 ? `${name.slice(0, 60)}…` : name;
152
+ const title = clip(name, 61);
151
153
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
152
154
  const body = {
153
155
  question: "needs an answer",
@@ -171,7 +173,7 @@ export function attentionText(session, attention) {
171
173
  // lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
172
174
  // tab goes on alone, at once.
173
175
  export const NOTIFY_CHANNEL = "chi-notify";
174
- export const NOTIFY_HOLD_MS = 300;
176
+ const NOTIFY_HOLD_MS = 300;
175
177
  // How long a key seen in front is remembered (its frame may come late).
176
178
  const SEEN_TTL_MS = 10 * 60 * 1000;
177
179
 
@@ -70,7 +70,7 @@ export function approvalView(pq) {
70
70
  const reason = a.reason ? String(a.reason) : "(no reason given)";
71
71
  return {
72
72
  // A plugin tool's label ("chrome: screenshot"), as its row shows it;
73
- // else the tool the call acts as (an MCP tool behind mcp_call).
73
+ // else the tool the call acts as (an MCP tool behind call_tool).
74
74
  tool: a.label || a.acts_as || a.tool || "",
75
75
  // A tool without a command or path (an MCP tool): its compact args.
76
76
  what: command || paths.join("\n") || a.args || "",
@@ -30,7 +30,7 @@ const SKIP = "pre, a, [data-ref-kind]";
30
30
  // The ref a match names: { path, line, endLine } or null. A path has a `/`
31
31
  // or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
32
32
  // a ref; an end line not after the line is dropped.
33
- export function refOf(groups) {
33
+ function refOf(groups) {
34
34
  const path = groups?.path || "";
35
35
  if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
36
36
  if (groups.from === undefined) return { path, line: null, endLine: null };
@@ -48,7 +48,17 @@ export function emptySessionState() {
48
48
  // Which of its reasoning the model gets back from the next turn on, with
49
49
  // its source (the reasoning history chip), or null (a native host).
50
50
  reasoningHistory: null,
51
+ toolkit: null,
51
52
  timing: normalizeTiming(),
53
+ // The event id (or, from an older worker, the event_seq) the session was
54
+ // rendered at (null: no live worker then), where a stream opened later
55
+ // has to start; set by the page's resync, not by the payload.
56
+ streamSeq: null,
57
+ // The stream was opened from 0 (a wake with no cursor to go on) and no
58
+ // new turn has started on it yet (app.js followWokenWorker).
59
+ streamFromGuess: false,
60
+ // When the info bar's "copy chi --attach" copied (its "copied ✓").
61
+ attachCopiedAt: 0,
52
62
  };
53
63
  }
54
64
 
@@ -64,6 +74,11 @@ export function sessionStateFrom(data) {
64
74
  served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
65
75
  dir: s.working_directory,
66
76
  owner: s.owner || null,
77
+ // Its live worker's chi and features (null and [] without one): a
78
+ // switch shows its stale-worker badge, task stop and queue at once,
79
+ // not at the session's next hub event (followSelectedSession).
80
+ workerVersion: s.worker_version || null,
81
+ workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
67
82
  timing: normalizeTiming(data.timing),
68
83
  };
69
84
  // The saved fill, so a reloaded or stopped session shows it before its
@@ -83,6 +98,8 @@ export function sessionStateFrom(data) {
83
98
  state.thinking = s.thinking || null;
84
99
  // null on a native host (it ignores the level) and from an older worker.
85
100
  state.reasoningHistory = s.reasoning_history || null;
101
+ // The tools chip's (null on full).
102
+ state.toolkit = s.toolkit || null;
86
103
  state.parentId = s.parent_id || null;
87
104
  state.continues = s.continues || null;
88
105
  state.commands = Array.isArray(data.commands) ? data.commands : [];
@@ -1,4 +1,4 @@
1
- import { normalize } from "./format.js";
1
+ import { clip, normalize } from "./format.js";
2
2
 
3
3
  // Where the open session's title shows: once on screen. Its strip card says
4
4
  // it when the strip is shown and has an `.active` card for it; otherwise
@@ -21,6 +21,5 @@ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
21
21
  export function tabTitle({ title, base }) {
22
22
  const text = normalize(title);
23
23
  if (!text) return base;
24
- const cut = text.length > TAB_TITLE_CHARS ? `${text.slice(0, TAB_TITLE_CHARS)}…` : text;
25
- return `${cut} · ${base}`;
24
+ return `${clip(text, TAB_TITLE_CHARS + 1)} · ${base}`;
26
25
  }
@@ -188,14 +188,6 @@ export function familySummary(f) {
188
188
  return delegatesSummary(f.members.map((m) => m.session));
189
189
  }
190
190
 
191
- // The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
192
- export function familyChipText(sum) {
193
- if (!sum) return "";
194
- const waiting = sum.counts?.waiting || 0;
195
- return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
196
- sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
197
- }
198
-
199
191
  // Whether a family's list is open: the user's toggle (+toggles+, head id →
200
192
  // bool) wins; else open when a member waits on the user (the head's own
201
193
  // wait is on its card already) or a search matched a member.
@@ -15,9 +15,9 @@ const FILE_TOOLS = new Set(["read", "edit", "write"]);
15
15
  const TASK_TOOLS = new Set(["task_wait", "task_get", "task_stop"]);
16
16
  const TRAIL = 3;
17
17
  // The hand-off waits this long after the stage was last in use.
18
- export const QUIET_MS = 1500;
18
+ const QUIET_MS = 1500;
19
19
  // A touch, wheel or scroll counts as use this long (phones have no hover).
20
- export const TOUCH_MS = 4000;
20
+ const TOUCH_MS = 4000;
21
21
 
22
22
  // One headline sentence as plain text: the markdown a streaming answer
23
23
  // carries (headings, quotes, bullets, table pipes, emphasis, code ticks,
@@ -229,6 +229,11 @@ export function createStreamHandlers(deps) {
229
229
  updateInfoBar();
230
230
  reasoningHistoryControl.refresh();
231
231
  }
232
+ // /toolkit changed what the next turn declares; null: full (no chip).
233
+ if ("toolkit" in data) {
234
+ state().toolkit = data.toolkit || null;
235
+ updateInfoBar();
236
+ }
232
237
  if (view.resync) await resync();
233
238
  // A card's action that isn't anytime (the step-limit question's
234
239
  // /continue) has no bubble: nothing to remove. An anytime or queued
@@ -246,6 +251,9 @@ export function createStreamHandlers(deps) {
246
251
  // message renders as one) — no second bubble, or the output shows twice.
247
252
  else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
248
253
  else addCommandBubble(view);
254
+ // This tab's command refused as busy (a plugin's command mid-turn): the
255
+ // composer was cleared when it went; its line comes back to send later.
256
+ if (view.own && view.busy && composer.empty()) composer.refill(view.line);
249
257
  },
250
258
  // An anytime command (/help, a plugin's /btw) runs at once, beside a
251
259
  // turn: its bubble goes here, so the cards it shows come after it. One
@@ -301,6 +309,11 @@ export function createStreamHandlers(deps) {
301
309
  // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
302
310
  // not even in the stage trail; a joined turn's notices come here too.
303
311
  hook_notice: (data) => {
312
+ // A plugin put the session on another toolkit (ctx.use_toolkit).
313
+ if ("toolkit" in data) {
314
+ state().toolkit = data.toolkit || null;
315
+ updateInfoBar();
316
+ }
304
317
  if (shownElsewhere(data, capabilities())) return;
305
318
  if (!turnDom.notice(data)) showHookNotice(data);
306
319
  },
@@ -3,9 +3,9 @@ import { waitingOn } from "./notify.js";
3
3
  // How many session cards the top strip shows for its width in px: a card
4
4
  // needs about 360 px plus a 12 px gap next to the 150 px "All sessions" tile;
5
5
  // never fewer than 3 (narrow screens scroll them), never more than 6.
6
- export const STRIP_TILE_PX = 150;
7
- export const STRIP_GAP_PX = 12;
8
- export const STRIP_CARD_PX = 360;
6
+ const STRIP_TILE_PX = 150;
7
+ const STRIP_GAP_PX = 12;
8
+ const STRIP_CARD_PX = 360;
9
9
 
10
10
  export function stripColumns(width) {
11
11
  const fit = Math.floor(((width || 0) - STRIP_TILE_PX - STRIP_GAP_PX) / (STRIP_CARD_PX + STRIP_GAP_PX));
@@ -6,9 +6,10 @@
6
6
  // button disabled. Pure but for the injected calls, so `node --test`
7
7
  // covers it; turn_view.js and stage_view.js draw the button.
8
8
  import { stopTask as postStopTask } from "./data.js";
9
+ import { clip } from "./format.js";
9
10
 
10
11
  // What a worker that has the route names in its sidecar features.
11
- export const TASK_STOP_FEATURE = "task_stop";
12
+ const TASK_STOP_FEATURE = "task_stop";
12
13
  const COMMAND_SHOWN = 120;
13
14
 
14
15
  // The task a row's button would stop: a running task_wait's (its server
@@ -61,7 +62,7 @@ export function taskCommand(gens, taskId) {
61
62
 
62
63
  export function confirmText(taskId, command) {
63
64
  const line = String(command || "").replace(/\s+/g, " ").trim();
64
- const what = !line ? taskId : line.length > COMMAND_SHOWN ? `${line.slice(0, COMMAND_SHOWN - 1)}…` : line;
65
+ const what = line ? clip(line, COMMAND_SHOWN) : taskId;
65
66
  return `Stop task ${what}?`;
66
67
  }
67
68
 
@@ -0,0 +1,29 @@
1
+ // The session bar's tools chip: the session's toolkit when it isn't full
2
+ // ("tools lean"), with the tools it leaves out and how to change them in
3
+ // its tooltip. The summary is the session's toolkit ({name, label,
4
+ // left_out, fallback}: Toolkit::Explained#summary; the worker's snapshot or
5
+ // a command_ran; the server's own reading of the session file has no
6
+ // left_out). Changing it is /toolkit (no popover yet).
7
+
8
+ import { html, nothing } from "./vendor/lit-html.js";
9
+
10
+ // The chip's text and tooltip; null without a summary (full).
11
+ export function chipModel(summary) {
12
+ if (!summary) return null;
13
+ const label = summary.label || summary.name;
14
+ const parts = [`Toolkit: ${label}, the tools this session declares.`];
15
+ if (summary.fallback) parts.push(`${summary.fallback}: every tool is declared (full) until it resolves.`);
16
+ if (Array.isArray(summary.left_out)) {
17
+ parts.push(summary.left_out.length ? `Not loaded: ${summary.left_out.join(", ")}.` : "Nothing left out.");
18
+ }
19
+ parts.push("/toolkit lists them; /toolkit NAME, add TOOL or remove TOOL changes them from the next turn.");
20
+ return { text: `tools ${label}${summary.fallback ? " ?" : ""}`, title: parts.join(" ") };
21
+ }
22
+
23
+ // The session bar's chip (a lit-html template; info_bar.js), nothing on
24
+ // full.
25
+ export function chipTemplate(summary) {
26
+ const chip = chipModel(summary);
27
+ if (!chip) return nothing;
28
+ return html`<span class="model tools-chip" title=${chip.title}>${chip.text}</span>`;
29
+ }
@@ -26,7 +26,7 @@ export function workerGoneText({ turnRunning, stopped = false }) {
26
26
 
27
27
  // How long a turn's answer waits for the after_turn hooks' answer_display
28
28
  // before the page stops waiting and pops what it has (4.14).
29
- export const DISPLAY_WAIT_MS = 20000;
29
+ const DISPLAY_WAIT_MS = 20000;
30
30
 
31
31
  // The wait between a turn_completed with display_pending and its
32
32
  // answer_display. The worker can die in between (a crash, a stop, an idle
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
8
8
  import { commandBlockTemplate, rowHover } from "./command_view.js";
9
9
  import { copySource } from "./copy.js";
10
10
  import { activityDiffTemplate } from "./diff_view.js";
11
- import { steerSender } from "./format.js";
11
+ import { clip, steerSender } from "./format.js";
12
12
  import { rowTitleRef } from "./refs.js";
13
13
  import { readLinesTemplate } from "./read_view.js";
14
14
  import { STOPPED_LINE_CLASS, cancelLineText, formatDuration } from "./timing.js";
@@ -105,7 +105,7 @@ export function toolRowInnerTemplate(row, { thumbs = () => nothing, stop = nothi
105
105
  const duration = formatDuration(row.duration_ms);
106
106
  const full = row.status === "running" ? "" : String(row.output || "").replace(/^\[[^\]]*\]\s*/, "");
107
107
  const output = full
108
- ? html`<div class="activity-output" title=${full}>${full.length > 300 ? `${full.slice(0, 300)}…` : full}</div>`
108
+ ? html`<div class="activity-output" title=${full}>${clip(full, 301)}</div>`
109
109
  : nothing;
110
110
  const status = statusParts(row.status, { noMatch: !!row.no_match });
111
111
  return html`${statusMarkTemplate(status)}<span class="activity-tool">${toolName(row)}</span>${params}${stop}${alias}${statusWordTemplate(status)}${duration ? html`<span class="activity-duration">${duration}</span>` : nothing}${commandBlockTemplate(row.view, row.title)}${llmEditTemplate(row)}${output}${full ? readLinesTemplate(row) : nothing}${row.images?.length ? thumbs(row.images) : nothing}${row.diff ? activityDiffTemplate(row.diff) : nothing}`;
@@ -25,7 +25,7 @@
25
25
  // - the turn's end closes the current gen.
26
26
  import { addCompleted, addStarted, newActivity, toolName } from "./activity.js";
27
27
  import { tallyText } from "./tally.js";
28
- import { leadTrimmed } from "./format.js";
28
+ import { clip, leadTrimmed } from "./format.js";
29
29
 
30
30
  const LABEL_MAX = 80;
31
31
  const LABEL_TOOLS = 3;
@@ -194,7 +194,7 @@ function isStep(gen) {
194
194
  }
195
195
 
196
196
  function cut(line) {
197
- return line.length > LABEL_MAX ? `${line.slice(0, LABEL_MAX).trimEnd()}…` : line;
197
+ return clip(line, LABEL_MAX + 1);
198
198
  }
199
199
 
200
200
  // "3 tool calls (1 failed)": failed = status error, the tally's predicate
@@ -58,7 +58,7 @@ function closedLabel(gen) {
58
58
  // that would repeat "thinking"). +body+ is the text: a string, or the live
59
59
  // step's Text node (appended by delta: a selection in it survives).
60
60
  // +cls+ "bubble thinking" is a plain answer's collapsed thinking block.
61
- export function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking", on = {} }) {
61
+ function thinkingTemplate({ body, wrapped = true, line = null, cls = "thinking", on = {} }) {
62
62
  const inner = html`<div class="thinking-body">${body}</div>`;
63
63
  if (!wrapped) return inner;
64
64
  const summary = line === null ? "thinking" : html`<span class="thinking-tag">thinking · </span>${line}`;
@@ -241,10 +241,6 @@ module Samagotchi
241
241
  knows_dir(InfoProbe.call(host, port, timeout: timeout))
242
242
  end
243
243
 
244
- def self.probe_verdict(status, body)
245
- knows_dir(InfoProbe.verdict(status, body))
246
- end
247
-
248
244
  def self.knows_dir(info)
249
245
  info.is_a?(Hash) && !Array(info["features"]).include?("dir") ? :other : info
250
246
  end
@@ -218,7 +218,7 @@ module Samagotchi
218
218
  # The session, its Engine and what runs its turns, before anyone can
219
219
  # reach it.
220
220
  def start
221
- # The wake turns' budget (delegate reports, attached context), from now.
221
+ # The wake turns' budget (delegate reports, attached context, peers), from now.
222
222
  @wakes = WorkerWakes.new(grace: WAKE_START_GRACE)
223
223
  # This worker holds the session now (a continue's starting marker held
224
224
  # off any other until it did), and a continue that died moving
@@ -272,7 +272,8 @@ module Samagotchi
272
272
  predecessor: @session.continues)
273
273
  # Its attached context's notes (ContextSources).
274
274
  @context_absorber = ContextAbsorber.new(session_id: @session_id, state_dir: @state_dir,
275
- project_root: @session.project_root)
275
+ project_root: @session.project_root,
276
+ peer_sources: @engine.peer_sources, peer_turns: -> { @wakes.peer_turns })
276
277
  # What comes in between turns, and how each kind is saved.
277
278
  @inbound = WorkerInbound.new(session: @session, state_dir: @state_dir, session_dir: @session_dir, engine: @engine,
278
279
  context_absorber: @context_absorber, wakes: @wakes,
@@ -496,13 +497,14 @@ module Samagotchi
496
497
  # A turn nobody typed for a changed source (a context wake): a continue
497
498
  # turn from context:<name> whose first message is the wake note already
498
499
  # in the conversation (named by +turn_id+), on the same rules as a
499
- # delegate report's wake turn: it counts toward session.max_wakes, and
500
- # a failure goes back to before the turn (the note stays, as a plain
501
- # one: WorkerInbound#unmark_wake) and pauses wakes until a human's input.
500
+ # delegate report's wake turn: it counts toward session.max_wakes (a
501
+ # peer's answer toward context.peer_turns too), and a failure goes back
502
+ # to before the turn (the note stays, as a plain one:
503
+ # WorkerInbound#unmark_wake) and pauses wakes until a human's input.
502
504
  # +context_wake+: a WorkerInbound::ContextWake.
503
505
  def run_context_wake_turn(context_wake)
504
506
  name = context_wake.name
505
- Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!)
507
+ Log.info(:worker, "context_wake_turn", name: name, in_a_row: @wakes.count!(peer: context_wake.peer?))
506
508
  @turn_flow.before_prompt_turn
507
509
  run_engine_turn(nil, continue: true, origin: context_wake.origin,
508
510
  id: context_wake.turn_id, max_iterations: IterationLimit.for) do |result, error|
@@ -878,6 +880,8 @@ module Samagotchi
878
880
  # /reasoning-history or /model: the level the next turn runs under
879
881
  # (the web's chip); nil on a native host, which ignores it.
880
882
  event[:reasoning_history] = @engine.reasoning_history_explained&.summary if changed.intersect?(%i[reasoning_history model])
883
+ # /toolkit: what the next turn declares (the web's chip; nil on full).
884
+ event[:toolkit] = @engine.toolkit_summary if changed.include?(:toolkit)
881
885
  event[:confirm] = confirm.to_h if confirm
882
886
  event[:output_truncated] = true if text.length > COMMAND_OUTPUT_LIMIT
883
887
  @engine.announce(event)
@@ -1218,12 +1222,13 @@ module Samagotchi
1218
1222
 
1219
1223
  # A human's input (ClientId.human?) un-archives the session;
1220
1224
  # a delegate's, a plugin's or a reminder's doesn't.
1221
- # It also resets the delegate-report wakes (budget and pause).
1225
+ # It also resets the wakes (budget and pause); input typed in the web or
1226
+ # a TUI the peer wakes too (chi send doesn't: a peer can run it).
1222
1227
  def user_input(client_id)
1223
1228
  return unless ClientId.human?(client_id)
1224
1229
 
1225
1230
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
1226
- @wakes.human_input!
1231
+ @wakes.human_input!(interactive: ClientId.interactive?(client_id))
1227
1232
  end
1228
1233
 
1229
1234
  def stopped_on_disk?
@@ -4,12 +4,14 @@ require "fileutils"
4
4
  require "securerandom"
5
5
  require "time"
6
6
 
7
+ require_relative "child_reports"
7
8
  require_relative "client_id"
8
9
  require_relative "config"
9
10
  require_relative "context_note"
10
11
  require_relative "log"
11
12
  require_relative "session"
12
13
  require_relative "session_inbox"
14
+ require_relative "worker_wakes"
13
15
 
14
16
  module Samagotchi
15
17
  # What comes into a worker's session between turns, each kind with its
@@ -43,6 +45,8 @@ module Samagotchi
43
45
  # the turn +turn_id+.
44
46
  ContextWake = Data.define(:delivery, :turn_id) do
45
47
  def name = delivery.name
48
+ # For a peer agent's answer (its own budget, WorkerWakes#peer_open?).
49
+ def peer? = !delivery.peer.nil?
46
50
  def origin = { client_id: "#{ClientId::CONTEXT_PREFIX}#{name}" }
47
51
  end
48
52
 
@@ -230,14 +234,42 @@ module Samagotchi
230
234
  # context.wake is on, the wake budget is open (WorkerWakes#context_open?:
231
235
  # no continue offer, not paused, under session.max_wakes, past the start
232
236
  # grace) and the source hasn't woken the session in the last
233
- # CONTEXT_WAKE_WINDOW. The others stay plain notes. Within the start
234
- # grace a change is a note: it came while the session was away.
237
+ # CONTEXT_WAKE_WINDOW; a peer's answer (Delivery#peer) has no window but
238
+ # the peer budget (WorkerWakes#peer_open?). The others stay plain notes.
239
+ # Within the start grace a change is a note: it came while the session
240
+ # was away. A peer's answer held by either budget is announced once.
235
241
  def context_wake_for(batch, now: Time.now)
236
242
  candidates = batch.deliveries.select(&:wake_note)
237
243
  return nil if candidates.empty? || !context_wakes_on?
238
- return nil unless @wakes.context_open?(awaiting_continue: @awaiting_continue.call, names: candidates.map(&:name))
239
244
 
240
- candidates.find { |delivery| !woke_lately?(delivery.subscription, now) }
245
+ unless @wakes.context_open?(awaiting_continue: @awaiting_continue.call, names: candidates.map(&:name))
246
+ peer_held(candidates.find(&:peer), ChildRing::MAX_WAKES_KEY) if @wakes.spent?
247
+ return nil
248
+ end
249
+
250
+ held = []
251
+ waking = candidates.select do |delivery|
252
+ next !woke_lately?(delivery.subscription, now) unless delivery.peer
253
+
254
+ next true if @wakes.peer_open?(name: delivery.name)
255
+
256
+ held << delivery
257
+ false
258
+ end
259
+ peer_held(held.first, WorkerWakes::PEER_TURNS_KEY)
260
+ waking.first
261
+ end
262
+
263
+ # A peer's answer waits as a note: the UIs hear it once per
264
+ # interactive input (WorkerWakes#notice_peer_budget!). +key+: the
265
+ # budget that holds it.
266
+ def peer_held(delivery, key)
267
+ return unless delivery && @wakes.notice_peer_budget!
268
+
269
+ turns = key == ChildRing::MAX_WAKES_KEY ? @wakes.max : @wakes.peer_turns
270
+ @engine.announce(type: :hook_notice, hook: "context", level: :info, between_turns: true,
271
+ text: "#{delivery.peer} answered again; it waits as a note for your next message " \
272
+ "(#{turns} wake turn#{"s" if turns != 1} in a row, #{key})")
241
273
  end
242
274
 
243
275
  def context_wakes_on? = Config.get("context.wake") != false