agent-from-zero 0.35.2 → 0.35.4

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.
package/dist/ink/app.js CHANGED
@@ -4,12 +4,13 @@ import { statusCommand } from "../status.js";
4
4
  import React, { useState, useEffect, useRef } from "react";
5
5
  import { Box, Text, Static, useInput, useApp } from "ink";
6
6
  import chalk from "chalk";
7
- import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
7
+ import { COMPACTING_VERB, formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
8
8
  import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
9
9
  import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
10
10
  import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
11
11
  import { effortMenu, setEffort } from "../effort.js";
12
12
  import { useSlashCompletion } from "./completion.js";
13
+ import { commandNames, commandTokenRanges, inlineCommandGhost } from "../completion.js"; // which /command tokens to paint + the mid-text ghost suggestion
13
14
  import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
14
15
  import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
15
16
  import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
@@ -20,11 +21,13 @@ import { resetBoard } from "../board.js";
20
21
  import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, getToolActivity, getToolCallCount, cleanup as tuiCleanup } from "../tui.js";
21
22
  import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
22
23
  import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
24
+ import { currentSession, peerInboxPending, readPeerInbox, peerMessageContent, peersCommand, setSessionState, setSessionInfo, listPeers, findPeer, peerRow, sendPeerMessage, SCREEN_KINDS, MAX_PEER_TURNS } from "../peers.js"; // peer sessions: messages from other mini-agent windows start a turn while idle
23
25
  import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
24
26
  import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
25
- import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
27
+ import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle, flashTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
26
28
  import { isPlanMode, setPlanMode } from "../permissions.js";
27
- import { findSkill, userSkillMessages } from "../skills.js";
29
+ import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
30
+ import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
28
31
  import { extractMemories } from "../memory.js";
29
32
  import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
30
33
  import { runHooks } from "../hooks.js";
@@ -45,7 +48,9 @@ const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only rea
45
48
  // it two cells wide — the text after the glyph then jumps on every other frame.
46
49
  const SPINNER_FRAMES = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"];
47
50
  const SPINNER_COLOR = "#D77757"; // Claude Code's orange
51
+ const SKILL_COLOR = "#B1B9F9"; // Claude Code's lavender for a recognised /skill in the input
48
52
  const SHIMMER_PEAK = "#FFD2BD"; // the highlight that sweeps across the verb
53
+ const COMPACT_PEAK = "#E4E8FF"; // the same sweep over the lavender "Compacting conversation…"
49
54
  const SHIMMER_TICK_MS = 100; // one animation clock for glyph + shimmer → one repaint per tick
50
55
  const SHIMMER_RADIUS = 3; // how many cells the highlight fades over on each side
51
56
  // Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
@@ -75,8 +80,8 @@ function useTick() {
75
80
  }
76
81
  // The glyph lives in a fixed two-cell box: whatever the frame, the text after it
77
82
  // starts in the same column.
78
- function Glyph({ tick }) {
79
- return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: SPINNER_COLOR, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
83
+ function Glyph({ tick, color = SPINNER_COLOR }) {
84
+ return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: color, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
80
85
  }
81
86
  function Spinner() {
82
87
  return _jsx(Glyph, { tick: useTick() });
@@ -89,10 +94,10 @@ function mix(a, b, t) {
89
94
  // The light sweep: each character is coloured by its distance from a moving
90
95
  // highlight. Only COLOUR changes between frames — never a character, never a
91
96
  // width — so the line shimmers without a single cell moving.
92
- export function shimmer(text, pos) {
97
+ export function shimmer(text, pos, base = SPINNER_COLOR, peak = SHIMMER_PEAK) {
93
98
  return [...text].map((c, i) => {
94
99
  const glow = Math.max(0, 1 - Math.abs(i - pos) / SHIMMER_RADIUS);
95
- return chalk.hex(mix(SPINNER_COLOR, SHIMMER_PEAK, glow))(c);
100
+ return chalk.hex(mix(base, peak, glow))(c);
96
101
  }).join("");
97
102
  }
98
103
  // "✶ Thinking… (3s · ↓ 281 tokens · thought for 1s)": the verb (everything up to
@@ -100,12 +105,14 @@ export function shimmer(text, pos) {
100
105
  // between sweeps (the highlight travels past the end) keeps it calm.
101
106
  function SpinnerLine({ text }) {
102
107
  const tick = useTick();
103
- const plain = stripVTControlCharacters(text);
108
+ const [head, ...below] = text.split("\n"); // a status may carry extra lines (the compaction progress bar)
109
+ const plain = stripVTControlCharacters(head);
104
110
  const cut = plain.indexOf("…");
105
- const verb = cut >= 0 && text.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
111
+ const verb = cut >= 0 && head.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
106
112
  const period = verb.length + SHIMMER_RADIUS * 2 + 8;
107
113
  const pos = (tick % period) - SHIMMER_RADIUS;
108
- return (_jsxs(Box, { children: [_jsx(Glyph, { tick: tick }), _jsxs(Text, { color: SPINNER_COLOR, wrap: "truncate-end", children: [verb ? shimmer(verb, pos) : "", text.slice(verb.length)] })] }));
114
+ const [base, peak] = verb === COMPACTING_VERB ? [SKILL_COLOR, COMPACT_PEAK] : [SPINNER_COLOR, SHIMMER_PEAK]; // Claude Code draws compaction in lavender
115
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Glyph, { tick: tick, color: base }), _jsxs(Text, { color: base, wrap: "truncate-end", children: [verb ? shimmer(verb, pos, base, peak) : "", head.slice(verb.length)] })] }), below.map((line, i) => _jsxs(Text, { wrap: "truncate-end", children: [" ", line] }, i))] }));
109
116
  }
110
117
  // One committed line of conversation. `user` is a highlight bar; `answer` is a
111
118
  // finished reply rendered as markdown; `note` is already-formatted narration.
@@ -163,7 +170,7 @@ const EXIT_NOTES = {
163
170
  };
164
171
  export function App({ session, runTurn, clipboard }) {
165
172
  const { rows, columns } = useTerminalSize();
166
- const { client, messages, systemMessage, costMeter, skills: liveSkills, judge, autoMode, model, dir, branch, getStatus } = session;
173
+ const { client, messages, systemMessage, costMeter, skills: liveSkills, allSkills: everySkill, judge, autoMode, model, dir, branch, getStatus } = session;
167
174
  // Seed the scrollback with the welcome banner + the dim startup notices.
168
175
  const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
169
176
  const [status, setStatus] = useState(null); // the live spinner line
@@ -171,6 +178,8 @@ export function App({ session, runTurn, clipboard }) {
171
178
  const [detailOffset, setDetailOffset] = useState(0);
172
179
  const [live, setLive] = useState(null); // the streaming answer
173
180
  const [input, setInput] = useState(""); // the current input buffer
181
+ const [talkTo, setTalkTo] = useState(null); // /peers → talk: the input box sends to another session instead of this agent
182
+ const replyTo = useRef([]); // who gets this turn's final answer (the senders of "user" peer messages)
174
183
  const [images, setImages] = useState([]);
175
184
  const pasting = useRef(false);
176
185
  const imageSeq = useRef(0);
@@ -210,9 +219,12 @@ export function App({ session, runTurn, clipboard }) {
210
219
  modelListEndpoint.current = CONFIG.baseURL;
211
220
  void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
212
221
  }, [wantsModels, client]);
222
+ const typeaheadSkills = liveSkills().map((s) => ({ name: s.name, description: s.description, whenToUse: s.whenToUse, argNames: s.argNames, argumentHint: s.argumentHint, source: skillSource(s) }));
213
223
  const completion = useSlashCompletion(input, completing, {
214
- model: CONFIG.model, skills: liveSkills(), models: completionModels, servers: listMcpServers().map((s) => s.name),
215
- }, rows - 10);
224
+ model: CONFIG.model, skills: typeaheadSkills, models: completionModels, servers: listMcpServers().map((s) => s.name), usage: completing && input.startsWith("/") ? skillUsageScores() : undefined,
225
+ }, rows - 10, columns);
226
+ // A "/" later in the text: no list, just the rest of the best match as dim ghost text (Tab accepts).
227
+ const ghost = completing && cursor === input.length ? inlineCommandGhost(input, { skills: typeaheadSkills }) : null;
216
228
  useEffect(() => {
217
229
  const id = setInterval(() => setTick((t) => t + 1), 1000);
218
230
  return () => clearInterval(id);
@@ -239,7 +251,10 @@ export function App({ session, runTurn, clipboard }) {
239
251
  pushItem(display);
240
252
  clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
241
253
  clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
254
+ const senders = replyTo.current; // taken now, so a failed turn can't leak them into the next one
255
+ replyTo.current = [];
242
256
  setBusy(true);
257
+ setSessionState("busy", CONFIG.model); // other sessions see "busy" in list_peers
243
258
  const controller = new AbortController();
244
259
  const tstate = { controller, interrupted: false };
245
260
  turn.current = tstate; // expose it so Esc can interrupt this turn
@@ -273,6 +288,9 @@ export function App({ session, runTurn, clipboard }) {
273
288
  };
274
289
  runTurn(content, hooks)
275
290
  .then(async (result) => {
291
+ // A turn started by the user from another window: show them the answer there.
292
+ for (const to of senders)
293
+ sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
276
294
  if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
277
295
  note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
278
296
  saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
@@ -290,6 +308,7 @@ export function App({ session, runTurn, clipboard }) {
290
308
  .catch((e) => note(chalk.red(`[error] ${e.message}`)))
291
309
  .finally(() => {
292
310
  turn.current = null;
311
+ setSessionState("idle");
293
312
  setBusy(false);
294
313
  setStatus(null);
295
314
  setLive(null);
@@ -324,13 +343,131 @@ export function App({ session, runTurn, clipboard }) {
324
343
  return () => clearInterval(id);
325
344
  // eslint-disable-next-line react-hooks/exhaustive-deps
326
345
  }, [busy, pending]);
346
+ // The PEER idle processor: another mini-agent session messaged this one while
347
+ // it sat at the prompt. Like cron, start a turn so the agent answers on its
348
+ // own. Two agents answering each other could run forever (and bill for every
349
+ // hop), so after MAX_PEER_TURNS auto-started turns with no human input in
350
+ // between, messages wait in the inbox until you type something.
351
+ const peerTurns = useRef(0);
352
+ const peerHoldNoted = useRef(false);
353
+ useEffect(() => {
354
+ if (busy || pending || followUps.size)
355
+ return; // human follow-ups take priority
356
+ const id = setInterval(() => {
357
+ const waiting = peerInboxPending();
358
+ if (!waiting || turn.current)
359
+ return;
360
+ const fromUser = peerInboxPending(["user"]);
361
+ if (fromUser) {
362
+ // You, typing in another window: never held by the agent-to-agent cap.
363
+ const got = readPeerInbox(["user"]);
364
+ peerTurns.current = 0;
365
+ peerHoldNoted.current = false;
366
+ replyTo.current = [...new Set(got.map((m) => m.from.id))];
367
+ runConversationTurn(peerMessageContent(got), { kind: "user", text: `${got.map((m) => m.text).join("\n")} ${chalk.magenta(`(from your "${got[0].from.name}" window)`)}` });
368
+ return;
369
+ }
370
+ if (peerTurns.current >= MAX_PEER_TURNS) {
371
+ if (!peerHoldNoted.current)
372
+ note(chalk.yellow(`✉ ${waiting} peer message${waiting > 1 ? "s" : ""} waiting — paused after ${MAX_PEER_TURNS} automatic turns in a row; send anything to continue`));
373
+ peerHoldNoted.current = true;
374
+ return;
375
+ }
376
+ const got = readPeerInbox();
377
+ if (!got.length)
378
+ return;
379
+ peerTurns.current++;
380
+ const from = [...new Set(got.map((m) => `${m.from.name} (${m.from.cwd})`))].join(", ");
381
+ runConversationTurn(peerMessageContent(got), { kind: "note", text: chalk.magenta(`✉ message from ${from}`) });
382
+ }, 1000);
383
+ return () => clearInterval(id);
384
+ // eslint-disable-next-line react-hooks/exhaustive-deps
385
+ }, [busy, pending]);
386
+ // Replies and "which window are you?" pings are for the SCREEN, not the model:
387
+ // handled even mid-turn, never starting a model call.
388
+ useEffect(() => {
389
+ const id = setInterval(() => {
390
+ if (!peerInboxPending(SCREEN_KINDS))
391
+ return;
392
+ for (const m of readPeerInbox(SCREEN_KINDS)) {
393
+ if (m.kind === "identify") {
394
+ flashTerminalTitle();
395
+ note(chalk.bgMagenta.white.bold(` 👋 This window is session "${m.text}" — the "${m.from.name}" window is looking for it `));
396
+ }
397
+ else {
398
+ note(chalk.magenta(`↩ ${m.from.name} replied:`));
399
+ pushItem({ kind: "answer", text: m.text });
400
+ }
401
+ }
402
+ }, 1000);
403
+ return () => clearInterval(id);
404
+ // eslint-disable-next-line react-hooks/exhaustive-deps
405
+ }, []);
406
+ // /peers: pick another session, then talk to it (the input box addresses it)
407
+ // or make it identify itself (bell + flashing tab) so you know which window it is.
408
+ const openPeers = () => {
409
+ const peers = listPeers();
410
+ if (!peers.length)
411
+ return note(chalk.dim(peersCommand("/peers")));
412
+ openSelect(`Other mini-agent sessions — pick one · this one is "${currentSession()?.name ?? "?"}" (/rename <name> to change)`, [...peers.map(peerRow), "Cancel"], (i) => {
413
+ const p = peers[i];
414
+ if (!p)
415
+ return;
416
+ openSelect(`${p.name} · ${p.terminal}`, [`Talk to ${p.name} — type in this box, Esc to come back`, "Identify it — ring its bell and flash its tab title", "Cancel"], (j) => {
417
+ if (j === 0) {
418
+ setTalkTo(p);
419
+ note(chalk.magenta(`→ talking to "${p.name}" (${p.terminal}). What you type goes there; its answer comes back here. Esc returns to this agent.`));
420
+ }
421
+ else if (j === 1) {
422
+ const sent = sendPeerMessage(p.id, p.name, "identify");
423
+ note(sent.startsWith("[error]") ? chalk.yellow(sent) : chalk.dim(`(asked "${p.name}" to flash its tab — look for 👋 ${p.name} — HERE)`));
424
+ }
425
+ }, peerRow(p));
426
+ });
427
+ };
327
428
  // A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
328
429
  // mentions, then start the turn. Mirrors agent.ts's submit path.
329
430
  const submitLine = async (draft, attachments = [], immediate = false) => {
431
+ peerTurns.current = 0; // a human is here: peers may start turns again
432
+ peerHoldNoted.current = false;
433
+ // Talk mode: plain text goes to the chosen session, even while this agent works.
434
+ if (talkTo && !pending && !draft.startsWith("/")) {
435
+ const text = pastedTexts.expand(draft);
436
+ const alive = findPeer(talkTo.id);
437
+ if (!alive) {
438
+ setTalkTo(null);
439
+ return note(chalk.yellow(`"${talkTo.name}" is no longer running — back to this agent`));
440
+ }
441
+ const sent = sendPeerMessage(alive.id, text, "user");
442
+ if (sent.startsWith("[error]"))
443
+ return note(chalk.yellow(sent));
444
+ pushItem({ kind: "user", text: `→ ${alive.name}: ${draft}` });
445
+ if (alive.state === "busy")
446
+ note(chalk.dim(`("${alive.name}" is busy — it answers when its current turn ends)`));
447
+ return;
448
+ }
449
+ if (!draft.startsWith("/"))
450
+ setSessionInfo({ lastPrompt: draft });
330
451
  if (!pending && turn.current && (draft.startsWith("/") || draft === "exit" || draft === "quit")) {
331
452
  note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
332
453
  return;
333
454
  }
455
+ // A /command typed while a menu is up is a command, never a message for the
456
+ // model (a second "/peers" into the /peers picker used to become the
457
+ // conversation's first prompt). Close the menu, then run it — when idle.
458
+ if (pending && /^\/[\w:-]+(\s|$)/.test(draft)) {
459
+ if (turn.current)
460
+ return note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
461
+ setPending(null);
462
+ setFormState(null);
463
+ if (pending.kind === "form")
464
+ pending.resolve(null);
465
+ else if (pending.kind === "select")
466
+ pending.onChoose(-1);
467
+ if (await handleCommand(draft))
468
+ return;
469
+ return note(chalk.dim(`unknown command: ${draft} — try /help`));
470
+ }
334
471
  if (!pending && (draft === "exit" || draft === "quit"))
335
472
  return doExit();
336
473
  if (!pending && await handleCommand(draft))
@@ -375,7 +512,7 @@ export function App({ session, runTurn, clipboard }) {
375
512
  setFormState(null);
376
513
  if (pending.kind === "form")
377
514
  pending.resolve(null);
378
- else
515
+ else if (pending.kind === "select")
379
516
  pending.onChoose(-1);
380
517
  }
381
518
  if (immediate)
@@ -466,6 +603,11 @@ export function App({ session, runTurn, clipboard }) {
466
603
  }
467
604
  if (line.startsWith("/skills "))
468
605
  line = `/skill ${line.slice(8).trim()}`;
606
+ if (line === "/skills") {
607
+ setDetails(null);
608
+ setPending({ kind: "skills" });
609
+ return true;
610
+ } // Claude Code's manager: on / user-only / off per skill
469
611
  if (line === "/effort" || line.startsWith("/effort ")) {
470
612
  const target = CONFIG.model;
471
613
  const value = line.slice("/effort".length).trim();
@@ -491,6 +633,16 @@ export function App({ session, runTurn, clipboard }) {
491
633
  setAutoEnabled(autoMode.enabled);
492
634
  return true;
493
635
  }
636
+ if (line === "/peers") {
637
+ openPeers();
638
+ return true;
639
+ }
640
+ const peers = peersCommand(line); // /rename <name>
641
+ if (peers !== null) {
642
+ note(chalk.dim(peers));
643
+ refreshTerminalTitle();
644
+ return true;
645
+ }
494
646
  const info = runInfoCommand(line, { skills: liveSkills(), costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
495
647
  if (info !== null) {
496
648
  note(info);
@@ -557,6 +709,7 @@ export function App({ session, runTurn, clipboard }) {
557
709
  return true;
558
710
  }
559
711
  autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
712
+ recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
560
713
  runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
561
714
  return true;
562
715
  }
@@ -598,7 +751,7 @@ export function App({ session, runTurn, clipboard }) {
598
751
  }
599
752
  setBusy(true);
600
753
  try {
601
- await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note);
754
+ await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note, sink); // the same progress bar as automatic compaction
602
755
  }
603
756
  catch (err) {
604
757
  note(chalk.yellow(`compaction failed: ${err.message}`));
@@ -664,6 +817,8 @@ export function App({ session, runTurn, clipboard }) {
664
817
  tuiCleanup();
665
818
  return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
666
819
  }
820
+ if (pending?.kind === "skills")
821
+ return; // the /skills panel owns the keyboard while it is open
667
822
  const modifiedReturn = char === "[13;5u" || char === "[27;5;13~";
668
823
  // A submitted follow-up may still be passing hooks. A second Enter must
669
824
  // not approve the old action while that asynchronous preparation runs.
@@ -733,6 +888,11 @@ export function App({ session, runTurn, clipboard }) {
733
888
  statusRequest.current.abort();
734
889
  return;
735
890
  }
891
+ if (key.escape && talkTo && !pending && !input) {
892
+ setTalkTo(null);
893
+ note(chalk.dim(`(back to this agent — no longer talking to "${talkTo.name}")`));
894
+ return;
895
+ }
736
896
  if (key.escape && busy && !pending) {
737
897
  if (followUps.size) {
738
898
  interruptForFollowUp();
@@ -792,6 +952,13 @@ export function App({ session, runTurn, clipboard }) {
792
952
  return;
793
953
  }
794
954
  }
955
+ if (key.tab && ghost) { // accept the inline suggestion as "/name "
956
+ const next = input.slice(0, ghost.start) + `/${ghost.full} `;
957
+ setInput(next);
958
+ setCursor(next.length);
959
+ setHistIdx(null);
960
+ return;
961
+ }
795
962
  const completionAction = completion.handleKey(key, (value) => { setInput(value); setCursor(value.length); setHistIdx(null); });
796
963
  if (completionAction.handled && !completionAction.submit)
797
964
  return;
@@ -932,15 +1099,16 @@ export function App({ session, runTurn, clipboard }) {
932
1099
  const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
933
1100
  const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
934
1101
  const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
935
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
936
- const prompt = "❯ ";
937
- const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
1102
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "skills" && (_jsx(SkillsPanel, { skills: everySkill(), maxRows: Math.max(3, rows - 14), onClose: (summary) => { setPending(null); note(chalk.dim(summary)); } })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && pending.kind !== "skills" && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), pending?.kind !== "skills" && (_jsx(Box, { borderStyle: "round", borderColor: talkTo || planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
1103
+ const prompt = talkTo ? `→ ${talkTo.name} ❯ ` : "❯ ";
1104
+ const promptColor = talkTo ? "magenta" : planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
938
1105
  const cols = columns;
939
1106
  const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
940
1107
  const promptW = displayWidth(prompt);
941
1108
  const firstW = Math.max(1, innerWidth - promptW);
942
1109
  // Build visual lines display-width-aware (CJK, emoji = 2 cols).
943
1110
  const lines = [""];
1111
+ const lineStarts = [0]; // code-unit offset where each visual line begins (for skill highlighting)
944
1112
  let row = 0;
945
1113
  let col = 0;
946
1114
  // Cursor tracking: map code-unit offset → (line, display-column).
@@ -960,6 +1128,7 @@ export function App({ session, runTurn, clipboard }) {
960
1128
  row++;
961
1129
  col = 0;
962
1130
  lines[row] = "";
1131
+ lineStarts[row] = cu;
963
1132
  }
964
1133
  lines[row] += ch;
965
1134
  col += cw;
@@ -974,9 +1143,35 @@ export function App({ session, runTurn, clipboard }) {
974
1143
  if (cursorCol >= edge) {
975
1144
  cursorLine++;
976
1145
  cursorCol = 0;
977
- if (lines.length <= cursorLine)
1146
+ if (lines.length <= cursorLine) {
978
1147
  lines.push("");
1148
+ lineStarts.push(cu);
1149
+ }
979
1150
  }
1151
+ // "/<skill>" tokens anywhere in the draft are painted — the typeahead
1152
+ // only opens at the start, but a real skill name lights up wherever
1153
+ // it is. paint() splits a slice of a line into plain and lit runs.
1154
+ const lit = commandTokenRanges(input, commandNames({ skills: liveSkills() }));
1155
+ const isLit = (at) => lit.some(([a, b]) => at >= a && at < b);
1156
+ const paint = (text, from, keyBase) => {
1157
+ const runs = [];
1158
+ let run = "";
1159
+ let runLit = false;
1160
+ let at = from;
1161
+ const flush = () => { if (run)
1162
+ runs.push(_jsx(Text, { color: runLit ? SKILL_COLOR : undefined, children: run }, `${keyBase}${runs.length}`)); run = ""; };
1163
+ for (const ch of text) {
1164
+ const l = isLit(at);
1165
+ if (l !== runLit) {
1166
+ flush();
1167
+ runLit = l;
1168
+ }
1169
+ run += ch;
1170
+ at += ch.length;
1171
+ }
1172
+ flush();
1173
+ return runs;
1174
+ };
980
1175
  // Map display-column back to code-unit offset within the target line.
981
1176
  const cursorLineText = lines[cursorLine] ?? "";
982
1177
  let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
@@ -997,9 +1192,10 @@ export function App({ session, runTurn, clipboard }) {
997
1192
  const before = line.slice(0, cuOff);
998
1193
  const at = line.slice(cuOff, cuOff + 1) || " ";
999
1194
  const after = line.slice(cuOff + 1);
1000
- return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, i));
1195
+ const base = lineStarts[i] ?? 0;
1196
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(before, base, "b") }), _jsx(Text, { inverse: true, color: isLit(base + cuOff) ? SKILL_COLOR : undefined, children: ghost ? ghost.suffix[0] : at }), _jsx(Text, { children: paint(after, base + cuOff + at.length, "a") }), ghost && _jsx(Text, { dimColor: true, children: ghost.suffix.slice(1) })] }, i));
1001
1197
  }
1002
- return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
1198
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(line, lineStarts[i] ?? 0, "l") })] }, i));
1003
1199
  });
1004
- })() }), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
1200
+ })() })), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
1005
1201
  }
@@ -36,10 +36,12 @@ export const SESSION_HELP = `commands:
36
36
  /team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
37
37
  /tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
38
38
  /cron list scheduled cron jobs (schedule_cron) and their expressions
39
+ /peers pick another mini-agent session on this machine: talk to it from this box (Esc to come back), or make it flash its tab so you know which window it is
40
+ /rename <name> rename this session — the name other sessions use to reach it
39
41
  /undo revert the most recent file write (write_file / edit_file) this session
40
42
  /diff show every file changed this session, as a diff from where it started
41
43
  /resume list recent sessions in this project and continue one of them
42
- /skills list the reusable skills available in this project
44
+ /skills manage skills: turn each on, off, or user-only (search with /, sort with t)
43
45
  /skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
44
46
  exit leave (Ctrl+C does the same)
45
47
 
@@ -1,18 +1,19 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from "react";
3
3
  import { Box, Text } from "ink";
4
4
  import { slashCompletions } from "../completion.js";
5
5
  // Keep the full list in state, but render only a window around the selection.
6
6
  // Long lists must never grow Ink's redraw region beyond the terminal height.
7
- export function useSlashCompletion(input, enabled, context, maxRows) {
7
+ export function useSlashCompletion(input, enabled, context, maxRows, columns = 80) {
8
8
  const [selection, setSelection] = useState({ input: "", index: 0 });
9
9
  const [dismissed, setDismissed] = useState(null);
10
10
  useEffect(() => { setDismissed(null); }, [input]);
11
11
  const menu = enabled && dismissed !== input ? slashCompletions(input, context) : null;
12
12
  const items = menu?.items ?? [];
13
13
  const selected = Math.min(selection.input === input ? selection.index : 0, Math.max(0, items.length - 1));
14
- const count = Math.max(1, Math.min(8, maxRows));
14
+ const count = Math.max(1, Math.min(6, maxRows)); // Claude Code shows at most 6, centred on the selection
15
15
  const start = Math.max(0, Math.min(selected - Math.floor(count / 2), items.length - count));
16
+ const nameWidth = Math.min(Math.max(0, ...items.map((item) => item.value.length)) + 2, Math.floor(columns * 0.4));
16
17
  return {
17
18
  visible: menu !== null,
18
19
  handleKey: (key, replace) => {
@@ -30,14 +31,21 @@ export function useSlashCompletion(input, enabled, context, maxRows) {
30
31
  }
31
32
  if (key.tab || (key.return && !key.ctrl && !key.meta)) {
32
33
  const item = items[selected];
34
+ // Tab always just fills it in. Enter runs it — unless it takes
35
+ // arguments (a skill with `arguments`, or a command with sub-choices),
36
+ // then it fills "/name " so you can type them. (Claude Code's rule.)
33
37
  if (key.tab || item.children)
34
- replace(item.value + (item.children ? " " : ""));
38
+ replace(item.value + " ");
35
39
  else
36
40
  return { handled: true, submit: item.value };
37
41
  return { handled: true };
38
42
  }
39
43
  return { handled: false };
40
44
  },
41
- view: menu && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [items.slice(start, start + count).map((item, i) => (_jsxs(Text, { color: start + i === selected ? "cyan" : undefined, bold: start + i === selected, wrap: "truncate-end", children: [start + i === selected ? "› " : " ", item.value, " ", _jsx(Text, { dimColor: true, children: item.description.replace(/\s+/g, " ") })] }, item.value))), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: items.length ? `↑↓ choose · Tab complete · Enter select · Esc close · ${selected + 1}/${items.length}` : menu.hint || "No matching commands" })] })),
45
+ view: menu && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [items.slice(start, start + count).map((item, i) => {
46
+ const on = start + i === selected;
47
+ // Names in one column (at most 40% of the width); the description is one line, cut to fit.
48
+ return (_jsxs(Text, { color: on ? "#B1B9F9" : undefined, dimColor: !on, wrap: "truncate-end", children: [on ? "› " : " ", item.value.padEnd(nameWidth), item.description.replace(/\s+/g, " ")] }, item.value));
49
+ }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: items.length ? `↑↓ choose · Tab complete · Enter select · Esc close · ${selected + 1}/${items.length}` : menu.hint || "No matching commands" })] })),
42
50
  };
43
51
  }
@@ -12,4 +12,9 @@ export async function launchInk(opts = {}) {
12
12
  const session = await buildInkSession({ resume: opts.resume });
13
13
  const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
14
14
  await waitUntilExit(); // keep the process alive until the user quits
15
+ // Then leave for real. The cron poll, the settings watcher and friends keep
16
+ // the event loop busy, so without this the process lingers after the UI is
17
+ // gone — and the process.on("exit") cleanups (MCP servers, background jobs,
18
+ // this session's peer record) never run.
19
+ process.exit(0);
15
20
  }
package/dist/ink/setup.js CHANGED
@@ -6,6 +6,7 @@ import { buildSystemMessage, readProjectInstructions } from "../prompt.js";
6
6
  import { contextPercent } from "../context.js";
7
7
  import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
8
8
  import { gitBranch } from "../tui.js";
9
+ import { listPeers, registerSession, unregisterSession } from "../peers.js"; // peer sessions: other mini-agent processes on this machine
9
10
  import { newSessionId, latestSession } from "../session.js";
10
11
  import { initTelemetry, emit } from "../telemetry.js";
11
12
  import { runHooks } from "../hooks.js";
@@ -14,7 +15,7 @@ import { Judge } from "../judge.js";
14
15
  import { AutoMode } from "../auto.js";
15
16
  import { registerExternalTool } from "../tools.js";
16
17
  import { rememberTool, readMemory } from "../memory.js";
17
- import { currentSkills } from "../skills.js";
18
+ import { allSkills, currentSkills } from "../skills.js";
18
19
  import { killAllBackground } from "../background.js";
19
20
  import { killAllSubAgents } from "../loop.js";
20
21
  import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "../cron.js"; // cron scheduler (Day s14)
@@ -93,6 +94,13 @@ export async function buildInkSession(opts = {}) {
93
94
  messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
94
95
  const dir = path.basename(process.cwd());
95
96
  const branch = gitBranch();
97
+ // Peer sessions: join the machine-wide registry so other mini-agent windows
98
+ // can find and message this one (and this one them).
99
+ const me = registerSession({ cwd: process.cwd(), branch, model: CONFIG.model });
100
+ process.on("exit", unregisterSession);
101
+ const others = listPeers().length;
102
+ if (others)
103
+ notices.push(`(peers: this session is "${me.name}"; ${others} other session${others === 1 ? "" : "s"} online — /peers)`);
96
104
  const getStatus = () => ({
97
105
  ctxPct: contextPercent(messages), // % of the way to auto-compaction
98
106
  cost: costMeter.cost(),
@@ -107,6 +115,7 @@ export async function buildInkSession(opts = {}) {
107
115
  startedAt,
108
116
  costMeter,
109
117
  skills: () => currentSkills(),
118
+ allSkills: () => allSkills(),
110
119
  judge,
111
120
  autoMode,
112
121
  model: CONFIG.model,