talon-agent 3.14.0 → 3.15.1

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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +24 -10
  3. package/src/backend/kilo/factory.ts +26 -0
  4. package/src/backend/kilo/handler/message.ts +12 -6
  5. package/src/backend/kilo/handler/turn.ts +43 -40
  6. package/src/backend/kilo/index.ts +7 -1
  7. package/src/backend/kilo/server.ts +28 -32
  8. package/src/backend/kilo/sessions.ts +17 -0
  9. package/src/backend/opencode/factory.ts +26 -0
  10. package/src/backend/opencode/handler/message.ts +13 -5
  11. package/src/backend/opencode/handler/turn.ts +41 -32
  12. package/src/backend/opencode/index.ts +7 -1
  13. package/src/backend/opencode/server.ts +27 -16
  14. package/src/backend/opencode/sessions.ts +17 -0
  15. package/src/backend/remote-server/client.ts +1 -1
  16. package/src/backend/remote-server/events.ts +19 -6
  17. package/src/backend/remote-server/index.ts +16 -1
  18. package/src/backend/remote-server/lifecycle.ts +5 -1
  19. package/src/backend/remote-server/mcp.ts +196 -110
  20. package/src/backend/remote-server/one-shot.ts +53 -6
  21. package/src/backend/remote-server/providers.ts +12 -0
  22. package/src/backend/remote-server/session-helpers.ts +69 -0
  23. package/src/backend/remote-server/sessions.ts +67 -7
  24. package/src/backend/remote-server/sse-stream.ts +17 -10
  25. package/src/backend/remote-server/state.ts +6 -0
  26. package/src/backend/remote-server/turn-timeout.ts +71 -0
  27. package/src/core/mcp-hub/index.ts +22 -0
  28. package/src/frontend/shared/status-context.ts +161 -0
  29. package/src/frontend/telegram/callbacks/model.ts +48 -0
  30. package/src/frontend/telegram/commands/settings.ts +8 -2
  31. package/src/frontend/telegram/helpers/menu.ts +5 -2
  32. package/src/frontend/terminal/commands.ts +152 -0
  33. package/src/util/respawn.ts +66 -38
@@ -18,7 +18,13 @@ import {
18
18
  import {
19
19
  buildCacheDisplay,
20
20
  buildContextDisplay,
21
+ buildContextBreakdown,
22
+ estimateContextTokens,
23
+ apportionCells,
24
+ type ContextBreakdown,
25
+ type ContextSegmentKey,
21
26
  } from "../shared/status-context.js";
27
+ import { getRecentHistory } from "../../storage/history.js";
22
28
  import {
23
29
  formatDuration,
24
30
  formatTokenCount,
@@ -113,6 +119,63 @@ export function clearCommands(): void {
113
119
  nameIndex.clear();
114
120
  }
115
121
 
122
+ // ── /context rendering ───────────────────────────────────────────────────────
123
+
124
+ /** Each used segment gets its own colour; free is a dim hatch. */
125
+ const CONTEXT_SEGMENT_COLOR: Record<ContextSegmentKey, (s: string) => string> =
126
+ {
127
+ system: pc.blue,
128
+ tools: pc.yellow,
129
+ conversation: pc.cyan,
130
+ };
131
+
132
+ const CONTEXT_BAR_WIDTH = 42;
133
+
134
+ /**
135
+ * The segmented bar: one coloured run per segment (proportional to the window),
136
+ * then the free space as a dim `░` hatch. Cell counts come from
137
+ * `apportionCells`, so the runs always sum to exactly the bar width.
138
+ */
139
+ function renderContextBar(bd: ContextBreakdown): string {
140
+ const weights = bd.segments.map((s) => s.tokens);
141
+ if (bd.windowKnown) weights.push(bd.free);
142
+ const cells = apportionCells(weights, CONTEXT_BAR_WIDTH);
143
+ let bar = "";
144
+ bd.segments.forEach((s, i) => {
145
+ bar += CONTEXT_SEGMENT_COLOR[s.key]("█".repeat(cells[i] ?? 0));
146
+ });
147
+ if (bd.windowKnown) {
148
+ bar += pc.dim("░".repeat(cells[bd.segments.length] ?? 0));
149
+ }
150
+ return bar;
151
+ }
152
+
153
+ /** One aligned legend row per segment (and Free), colour-matched to the bar. */
154
+ function renderContextLegend(bd: ContextBreakdown): string[] {
155
+ const rows = bd.segments.map((s) => ({
156
+ dot: CONTEXT_SEGMENT_COLOR[s.key]("●"),
157
+ label: s.label,
158
+ tokens: s.tokens,
159
+ pct: s.pct,
160
+ }));
161
+ if (bd.windowKnown) {
162
+ rows.push({
163
+ dot: pc.dim("░"),
164
+ label: "Free",
165
+ tokens: bd.free,
166
+ pct: bd.freePct,
167
+ });
168
+ }
169
+ const labelW = Math.max(...rows.map((r) => r.label.length));
170
+ const tokW = Math.max(...rows.map((r) => formatTokenCount(r.tokens).length));
171
+ return rows.map((r) => {
172
+ const label = r.label.padEnd(labelW);
173
+ const tok = formatTokenCount(r.tokens).padStart(tokW);
174
+ const pct = `${r.pct}%`.padStart(6);
175
+ return `${r.dot} ${label} ${pc.dim(tok)} ${pc.dim(pct)}`;
176
+ });
177
+ }
178
+
116
179
  // ── Built-in commands ────────────────────────────────────────────────────────
117
180
 
118
181
  export function registerBuiltinCommands(): void {
@@ -393,6 +456,95 @@ export function registerBuiltinCommands(): void {
393
456
  },
394
457
  });
395
458
 
459
+ registerCommand({
460
+ name: "context",
461
+ aliases: ["ctx"],
462
+ description: "Context-window usage, broken down",
463
+ async handler(_args, ctx) {
464
+ const chatId = ctx.chatId();
465
+ const u = getSessionInfo(chatId).usage;
466
+ const be = ctx.backend;
467
+ const activeModel = getChatSettings(chatId).model ?? ctx.config.model;
468
+
469
+ // Window + a friendly model name, enriched from the backend like /status.
470
+ let contextWindow = u.contextWindow;
471
+ let modelName = resolveModelName(activeModel);
472
+ if (be?.models?.getRawModelInfo) {
473
+ const mi = await be.models
474
+ .getRawModelInfo(activeModel)
475
+ .catch(() => undefined);
476
+ if (mi) {
477
+ if (mi.contextWindow) contextWindow ||= mi.contextWindow;
478
+ if (mi.displayName) modelName = mi.displayName;
479
+ }
480
+ }
481
+
482
+ // System = the actual frozen prompt the model is running with. Measure
483
+ // it directly rather than rebuilding, so the number matches what was
484
+ // really sent (the prompt is frozen per session by design).
485
+ const parts = ctx.config.systemPromptParts;
486
+ const systemText = parts
487
+ ? [parts.staticText, parts.dynamicText].filter(Boolean).join("\n")
488
+ : (ctx.config.systemPrompt ?? "");
489
+ const systemTokens = estimateContextTokens(systemText);
490
+
491
+ // Conversation = stored history for this chat. An estimate: the model's
492
+ // real in-window history may be smaller after compaction, which the
493
+ // breakdown clamps against the authoritative fill.
494
+ const history = getRecentHistory(chatId, 2000);
495
+ const conversationTokens = estimateContextTokens(
496
+ history.map((m) => m.text ?? "").join("\n"),
497
+ );
498
+
499
+ const bd = buildContextBreakdown({
500
+ contextTokens: u.contextTokens,
501
+ contextWindow,
502
+ systemTokens,
503
+ conversationTokens,
504
+ });
505
+
506
+ ctx.renderer.writeln();
507
+ if (!bd.known) {
508
+ ctx.renderer.writeln(
509
+ ` ${pc.bold("Context")} ${pc.dim("no usage yet — send a message first")}`,
510
+ );
511
+ ctx.reprompt();
512
+ return;
513
+ }
514
+
515
+ const windowStr = bd.windowKnown
516
+ ? `${formatTokenCount(bd.max)} window`
517
+ : pc.dim("window unknown");
518
+ ctx.renderer.writeln(
519
+ ` ${pc.bold("Context")} ${modelName} · ${windowStr}`,
520
+ );
521
+
522
+ const usedStr = bd.windowKnown
523
+ ? `${formatTokenCount(bd.used)} / ${formatTokenCount(bd.max)} · ${bd.usedPct}% used`
524
+ : `${formatTokenCount(bd.used)} used`;
525
+ ctx.renderer.writeln(
526
+ ` ${bd.warn ? pc.yellow(usedStr) : pc.dim(usedStr)}${bd.warn ? pc.yellow(" · nearing limit") : ""}`,
527
+ );
528
+ ctx.renderer.writeln();
529
+ ctx.renderer.writeln(` ${renderContextBar(bd)}`);
530
+ ctx.renderer.writeln();
531
+ for (const line of renderContextLegend(bd)) {
532
+ ctx.renderer.writeln(` ${line}`);
533
+ }
534
+ // Explain only what's on screen: Tools is derivable (and shown) only
535
+ // when the backend reported a real fill.
536
+ const hasTools = bd.segments.some((s) => s.key === "tools");
537
+ ctx.renderer.writeln(
538
+ ` ${pc.dim(
539
+ hasTools
540
+ ? "System measured; Conversation estimated; Tools is the remainder."
541
+ : "System measured; Conversation estimated from stored history.",
542
+ )}`,
543
+ );
544
+ ctx.reprompt();
545
+ },
546
+ });
547
+
396
548
  registerCommand({
397
549
  name: "reset",
398
550
  description: "Start a fresh session",
@@ -3,8 +3,7 @@
3
3
  *
4
4
  * Spawns a fresh copy of the current process — same Node binary,
5
5
  * same `execArgv` (preserving the tsx loader so `.ts` entrypoints
6
- * still resolve), same script + user args, same cwd + env — then
7
- * triggers a graceful shutdown of the current process. The new
6
+ * still resolve), same script + user args, same cwd + env. The new
8
7
  * child is detached with stdio:"ignore" so it survives the parent's
9
8
  * exit; calling `unref()` lets the parent exit without waiting on
10
9
  * it.
@@ -16,59 +15,88 @@
16
15
  * debugger. Respawning from our own `process.argv` works regardless
17
16
  * of launch method.
18
17
  *
19
- * Shutdown is done by raising SIGTERM on ourselves rather than
20
- * `process.exit(0)` so the existing graceful-shutdown handler runs
21
- * (`await frontend.stop()`, flush sessions, remove PID file). The
22
- * brief overlap between the new child binding Telegram's long-poll
23
- * and the old one releasing it is benign — grammy retries on 409
24
- * Conflict and the new child takes over within a few seconds.
18
+ * Ordering matters. `respawnSelf()` only *arms* the handoff and
19
+ * raises SIGTERM; the successor is spawned by `spawnSuccessor()` at
20
+ * the tail of graceful shutdown, once the frontends have stopped.
21
+ * Spawning up-front (the previous behaviour) left the successor
22
+ * long-polling `getUpdates` while the outgoing process was still
23
+ * draining in-flight queries — up to DRAIN_TIMEOUT_MS of two live
24
+ * pollers. Telegram answers only one of them and re-delivers the
25
+ * unconfirmed updates to the other, so a restart mid-turn produced
26
+ * a 409 Conflict on the way out and duplicate replies on the way in.
27
+ * Releasing the poll before the successor binds it removes the
28
+ * overlap rather than relying on grammy's 409 retry to paper over it.
25
29
  */
26
30
 
27
31
  import { spawn } from "node:child_process";
28
32
  import { log, logError } from "./log.js";
29
33
 
34
+ let pendingReason: string | null = null;
35
+
30
36
  /**
31
- * Respawn the current process with identical argv + flags, then
32
- * raise SIGTERM on ourselves so the existing graceful-shutdown path
33
- * cleanly stops the frontend, flushes state, and exits.
37
+ * Arm a respawn and raise SIGTERM on ourselves so the existing
38
+ * graceful-shutdown path cleanly stops the frontends, flushes state,
39
+ * and hands off via `spawnSuccessor()`.
34
40
  *
35
41
  * `reason` is logged for operator visibility (e.g. "telegram
36
- * /restart"). The function returns immediately; the actual exit
37
- * happens asynchronously once the child has spawned (or failed).
42
+ * /restart"). The function returns immediately; the successor starts
43
+ * only after shutdown has released the Telegram long-poll.
38
44
  */
39
45
  export function respawnSelf(reason: string): void {
40
46
  log("shutdown", `Respawn requested (${reason})`);
47
+ pendingReason = reason;
48
+ // SIGTERM triggers the graceful-shutdown handler in src/app.ts,
49
+ // which stops the frontends, flushes state, spawns the successor,
50
+ // and calls process.exit(0). Don't exit here directly — that would
51
+ // skip the flush and leave the PID file dangling.
52
+ process.kill(process.pid, "SIGTERM");
53
+ }
41
54
 
42
- const child = spawn(
43
- process.argv[0],
44
- [...process.execArgv, ...process.argv.slice(1)],
45
- {
46
- cwd: process.cwd(),
47
- detached: true,
48
- stdio: "ignore",
49
- env: { ...process.env },
50
- },
51
- );
55
+ /** True when a `/restart` or `/update` armed a handoff. */
56
+ export function respawnRequested(): boolean {
57
+ return pendingReason !== null;
58
+ }
52
59
 
53
- child.once("spawn", () => {
54
- log("shutdown", `Respawn child started (pid ${child.pid}) — exiting self`);
55
- child.unref();
56
- // SIGTERM triggers the existing graceful-shutdown handler in
57
- // src/index.ts, which stops the frontend, flushes state, and
58
- // calls process.exit(0). Don't exit here directly — that would
59
- // skip the flush and leave the PID file dangling.
60
- process.kill(process.pid, "SIGTERM");
61
- });
60
+ /**
61
+ * Spawn the successor process. Called at the end of graceful
62
+ * shutdown, after the frontends have stopped — so the incoming
63
+ * process binds Telegram's long-poll only once this one has let go
64
+ * of it. No-op unless `respawnSelf()` armed a handoff.
65
+ *
66
+ * Never throws: a failed handoff must not prevent this process from
67
+ * exiting. An external supervisor (systemd, pm2, the user's
68
+ * terminal) can pick things up.
69
+ */
70
+ export function spawnSuccessor(): void {
71
+ if (pendingReason === null) return;
72
+ const reason = pendingReason;
73
+ pendingReason = null;
62
74
 
63
- child.once("error", (err) => {
75
+ try {
76
+ const child = spawn(
77
+ process.argv[0],
78
+ [...process.execArgv, ...process.argv.slice(1)],
79
+ {
80
+ cwd: process.cwd(),
81
+ detached: true,
82
+ stdio: "ignore",
83
+ env: { ...process.env },
84
+ },
85
+ );
86
+ child.once("error", (err) => {
87
+ logError(
88
+ "shutdown",
89
+ `Respawn failed; exiting without a successor — restart manually`,
90
+ err,
91
+ );
92
+ });
93
+ child.unref();
94
+ log("shutdown", `Respawn child started (pid ${child.pid}) — ${reason}`);
95
+ } catch (err) {
64
96
  logError(
65
97
  "shutdown",
66
98
  `Respawn failed; exiting without a successor — restart manually`,
67
99
  err,
68
100
  );
69
- // The child never started, so we can't hand off. Still exit so
70
- // any external supervisor (systemd, pm2, the user's terminal)
71
- // can pick things up.
72
- process.kill(process.pid, "SIGTERM");
73
- });
101
+ }
74
102
  }