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.
- package/package.json +1 -1
- package/src/app.ts +24 -10
- package/src/backend/kilo/factory.ts +26 -0
- package/src/backend/kilo/handler/message.ts +12 -6
- package/src/backend/kilo/handler/turn.ts +43 -40
- package/src/backend/kilo/index.ts +7 -1
- package/src/backend/kilo/server.ts +28 -32
- package/src/backend/kilo/sessions.ts +17 -0
- package/src/backend/opencode/factory.ts +26 -0
- package/src/backend/opencode/handler/message.ts +13 -5
- package/src/backend/opencode/handler/turn.ts +41 -32
- package/src/backend/opencode/index.ts +7 -1
- package/src/backend/opencode/server.ts +27 -16
- package/src/backend/opencode/sessions.ts +17 -0
- package/src/backend/remote-server/client.ts +1 -1
- package/src/backend/remote-server/events.ts +19 -6
- package/src/backend/remote-server/index.ts +16 -1
- package/src/backend/remote-server/lifecycle.ts +5 -1
- package/src/backend/remote-server/mcp.ts +196 -110
- package/src/backend/remote-server/one-shot.ts +53 -6
- package/src/backend/remote-server/providers.ts +12 -0
- package/src/backend/remote-server/session-helpers.ts +69 -0
- package/src/backend/remote-server/sessions.ts +67 -7
- package/src/backend/remote-server/sse-stream.ts +17 -10
- package/src/backend/remote-server/state.ts +6 -0
- package/src/backend/remote-server/turn-timeout.ts +71 -0
- package/src/core/mcp-hub/index.ts +22 -0
- package/src/frontend/shared/status-context.ts +161 -0
- package/src/frontend/telegram/callbacks/model.ts +48 -0
- package/src/frontend/telegram/commands/settings.ts +8 -2
- package/src/frontend/telegram/helpers/menu.ts +5 -2
- package/src/frontend/terminal/commands.ts +152 -0
- 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",
|
package/src/util/respawn.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
37
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|