talon-agent 3.34.0 → 3.35.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 (70) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +0 -10
  3. package/package.json +6 -4
  4. package/src/backend/claude-sdk/handler.ts +428 -415
  5. package/src/backend/codex/auth.ts +40 -1
  6. package/src/backend/codex/handler/message.ts +290 -366
  7. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  8. package/src/backend/codex/plan-usage.ts +27 -2
  9. package/src/backend/kilo/handler/message.ts +2 -0
  10. package/src/backend/kilo/server.ts +1 -0
  11. package/src/backend/openai-agents/handler/message.ts +282 -356
  12. package/src/backend/opencode/handler/message.ts +2 -0
  13. package/src/backend/opencode/server.ts +1 -0
  14. package/src/backend/remote-server/chat-turn.ts +76 -122
  15. package/src/backend/remote-server/index.ts +4 -0
  16. package/src/backend/remote-server/lifecycle.ts +37 -0
  17. package/src/backend/remote-server/mcp.ts +73 -9
  18. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  19. package/src/backend/remote-server/server-bindings.ts +12 -0
  20. package/src/backend/remote-server/sessions.ts +2 -2
  21. package/src/backend/remote-server/state.ts +8 -0
  22. package/src/backend/remote-server/turn.ts +93 -23
  23. package/src/backend/shared/cache-telemetry.ts +17 -1
  24. package/src/backend/shared/handler-to-events.ts +11 -16
  25. package/src/backend/shared/index.ts +14 -15
  26. package/src/backend/shared/result-events.ts +30 -0
  27. package/src/backend/shared/turn-phases.ts +277 -0
  28. package/src/bootstrap.ts +24 -10
  29. package/src/core/auth/expiry-monitor.ts +89 -0
  30. package/src/core/auth/login-flow.ts +247 -0
  31. package/src/core/auth/status.ts +193 -0
  32. package/src/core/background/triggers/exit.ts +19 -2
  33. package/src/core/background/triggers/resume.ts +14 -7
  34. package/src/core/background/triggers/state.ts +9 -0
  35. package/src/core/engine/gateway-actions/history.ts +12 -1
  36. package/src/core/errors.ts +1 -1
  37. package/src/core/mcp-hub/child-transport.ts +215 -0
  38. package/src/core/mcp-hub/children.ts +72 -13
  39. package/src/core/mcp-hub/index.ts +32 -13
  40. package/src/core/models/active-model.ts +29 -6
  41. package/src/core/tools/history.ts +10 -1
  42. package/src/core/weaver/shuttle.ts +4 -0
  43. package/src/frontend/shared/model-commands.ts +400 -0
  44. package/src/frontend/telegram/admin.ts +75 -52
  45. package/src/frontend/telegram/auth-panel.ts +205 -0
  46. package/src/frontend/telegram/callbacks/auth.ts +70 -0
  47. package/src/frontend/telegram/callbacks/index.ts +16 -0
  48. package/src/frontend/telegram/callbacks/whatsapp.ts +50 -0
  49. package/src/frontend/telegram/commands/auth.ts +58 -0
  50. package/src/frontend/telegram/commands/definitions.ts +4 -0
  51. package/src/frontend/telegram/commands/index.ts +10 -4
  52. package/src/frontend/telegram/commands/whatsapp-pairing.ts +121 -79
  53. package/src/frontend/whatsapp/actions/chat-info.ts +2 -36
  54. package/src/frontend/whatsapp/actions/history.ts +100 -0
  55. package/src/frontend/whatsapp/actions/index.ts +4 -1
  56. package/src/frontend/whatsapp/commands.ts +371 -0
  57. package/src/frontend/whatsapp/inbound.ts +24 -46
  58. package/src/frontend/whatsapp/index.ts +12 -1
  59. package/src/frontend/whatsapp/media-store.ts +20 -1
  60. package/src/frontend/whatsapp/message-store.ts +101 -24
  61. package/src/storage/history.ts +22 -1
  62. package/src/storage/repositories/history-repo.ts +14 -0
  63. package/src/storage/repositories/whatsapp-messages-repo.ts +101 -0
  64. package/src/storage/sql/history.sql +12 -0
  65. package/src/storage/sql/schema.sql +21 -0
  66. package/src/storage/sql/statements.generated.ts +50 -1
  67. package/src/storage/sql/whatsapp-messages.sql +29 -0
  68. package/src/storage/trigger-store.ts +8 -0
  69. package/src/storage/whatsapp-messages.ts +51 -0
  70. package/src/util/watchdog.ts +32 -7
@@ -69,8 +69,22 @@ export interface RunRemoteTurnInputs {
69
69
  onStreamDelta?: (accumulated: string, phase?: "thinking" | "text") => void;
70
70
  onTextBlock?: (text: string) => Promise<void>;
71
71
  onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
72
+ /**
73
+ * Fires when the backend is stopped underneath the turn (hot-swap or
74
+ * shutdown). Its `reason` is the error the turn rejects with.
75
+ */
76
+ stopSignal?: AbortSignal;
72
77
  }
73
78
 
79
+ /** Poll cadence for the headless question/permission watchdog. */
80
+ const WATCHDOG_INTERVAL_MS = 350;
81
+ /**
82
+ * Consecutive poll failures before the watchdog gives up. A server that
83
+ * refuses three polls in a row is gone; polling on would only log
84
+ * `fetch failed` every 350ms until the turn is torn down.
85
+ */
86
+ const WATCHDOG_MAX_CONSECUTIVE_FAILURES = 3;
87
+
74
88
  /**
75
89
  * Run one turn end-to-end.
76
90
  *
@@ -105,6 +119,7 @@ export async function runRemoteTurn(
105
119
  onStreamDelta,
106
120
  onTextBlock,
107
121
  onToolUse,
122
+ stopSignal,
108
123
  } = inputs;
109
124
  const sessionClient = oc as unknown as RemoteSessionClient;
110
125
 
@@ -112,6 +127,10 @@ export async function runRemoteTurn(
112
127
  // `message.part.updated` events can fire immediately after promptAsync
113
128
  // returns, so the iterator must already be alive.
114
129
  const sseAbort = new AbortController();
130
+ // A backend stop ends the SSE loop and the watchdog the same way a
131
+ // finished turn does; the turn itself rejects via `rejectWhenStopped`.
132
+ const onStop = (): void => sseAbort.abort();
133
+ stopSignal?.addEventListener("abort", onStop, { once: true });
115
134
  const sseDone = subscribeToTurnEvents({
116
135
  label,
117
136
  oc,
@@ -144,35 +163,30 @@ export async function runRemoteTurn(
144
163
  label,
145
164
  ),
146
165
  ]);
147
- const questionWatchdog = (async () => {
148
- while (!sseAbort.signal.aborted) {
149
- try {
150
- await settlePending();
151
- } catch (err) {
152
- logWarn(
153
- "agent",
154
- `[${chatId}] question watchdog failed: ${errMsg(err)}`,
155
- );
156
- }
157
- await sleep(350, sseAbort.signal);
158
- }
159
- })();
166
+ const questionWatchdog = runQuestionWatchdog({
167
+ settlePending,
168
+ signal: sseAbort.signal,
169
+ chatId,
170
+ });
160
171
 
161
172
  try {
162
173
  // Fire and forget — promptAsync returns immediately. The await below
163
174
  // is on the SSE close event. Per-prompt overrides hide sibling chats'
164
175
  // MCP tools while session permissions independently deny execution.
165
176
  await awaitRemoteTurn(
166
- (async () => {
167
- await oc.session.promptAsync({
168
- sessionID: sessionId,
169
- parts: [{ type: "text", text: prompt }],
170
- model: { providerID, modelID },
171
- system: systemPrompt,
172
- ...(toolOverrides ? { tools: toolOverrides } : {}),
173
- });
174
- await sseDone;
175
- })(),
177
+ rejectWhenStopped(
178
+ (async () => {
179
+ await oc.session.promptAsync({
180
+ sessionID: sessionId,
181
+ parts: [{ type: "text", text: prompt }],
182
+ model: { providerID, modelID },
183
+ system: systemPrompt,
184
+ ...(toolOverrides ? { tools: toolOverrides } : {}),
185
+ });
186
+ await sseDone;
187
+ })(),
188
+ stopSignal,
189
+ ),
176
190
  { client: oc, sessionId, chatId, label },
177
191
  );
178
192
 
@@ -214,6 +228,7 @@ export async function runRemoteTurn(
214
228
  }
215
229
  throw err;
216
230
  } finally {
231
+ stopSignal?.removeEventListener("abort", onStop);
217
232
  sseAbort.abort();
218
233
  // A dead SSE socket may ignore the local abort flag until another event
219
234
  // arrives. Bound cleanup so a timed-out turn cannot wedge its caller in
@@ -228,6 +243,61 @@ export async function runRemoteTurn(
228
243
  }
229
244
  }
230
245
 
246
+ /**
247
+ * Race the turn against a backend stop. A stop rejects with the signal's
248
+ * `reason` (a `RemoteServerStoppedError`) the moment it fires, instead of
249
+ * waiting for an SSE socket that will never close on its own.
250
+ */
251
+ function rejectWhenStopped<T>(
252
+ turn: Promise<T>,
253
+ stopSignal: AbortSignal | undefined,
254
+ ): Promise<T> {
255
+ if (!stopSignal) return turn;
256
+ if (stopSignal.aborted) return Promise.reject(stopSignal.reason);
257
+ return new Promise<T>((resolve, reject) => {
258
+ const onAbort = (): void => reject(stopSignal.reason);
259
+ stopSignal.addEventListener("abort", onAbort, { once: true });
260
+ turn.then(resolve, reject).finally(() => {
261
+ stopSignal.removeEventListener("abort", onAbort);
262
+ });
263
+ });
264
+ }
265
+
266
+ /**
267
+ * Poll the pending question/permission lists until the turn's signal
268
+ * fires. Gives up after {@link WATCHDOG_MAX_CONSECUTIVE_FAILURES} failed
269
+ * polls in a row — a server that stopped answering is not coming back
270
+ * within this turn, and the finally-block settle runs once more anyway.
271
+ */
272
+ async function runQuestionWatchdog(inputs: {
273
+ settlePending: () => Promise<unknown>;
274
+ signal: AbortSignal;
275
+ chatId: string;
276
+ }): Promise<void> {
277
+ const { settlePending, signal, chatId } = inputs;
278
+ let consecutiveFailures = 0;
279
+ while (!signal.aborted) {
280
+ try {
281
+ await settlePending();
282
+ consecutiveFailures = 0;
283
+ } catch (err) {
284
+ consecutiveFailures += 1;
285
+ logWarn(
286
+ "agent",
287
+ `[${chatId}] question watchdog failed (${consecutiveFailures}/${WATCHDOG_MAX_CONSECUTIVE_FAILURES}): ${errMsg(err)}`,
288
+ );
289
+ if (consecutiveFailures >= WATCHDOG_MAX_CONSECUTIVE_FAILURES) {
290
+ logWarn(
291
+ "agent",
292
+ `[${chatId}] question watchdog stopped: server unreachable`,
293
+ );
294
+ return;
295
+ }
296
+ }
297
+ await sleep(WATCHDOG_INTERVAL_MS, signal);
298
+ }
299
+ }
300
+
231
301
  interface SubscribeInputs {
232
302
  label: string;
233
303
  oc: RemoteTurnClient;
@@ -249,10 +249,23 @@ function estimateTokens(text: string): number {
249
249
  return Math.ceil(text.length / 4);
250
250
  }
251
251
 
252
+ /**
253
+ * (label, model) pairs already warned about this process. A recurring
254
+ * caller (heartbeat, dream) builds the same prompt on every run, so the
255
+ * first warning says everything the rest would.
256
+ */
257
+ const warnedCacheMinimums = new Set<string>();
258
+
259
+ /** Test seam. */
260
+ export function resetCacheMinimumWarnings(): void {
261
+ warnedCacheMinimums.clear();
262
+ }
263
+
252
264
  /**
253
265
  * Warn when a prompt is too small to be cacheable on its model. No-op when
254
266
  * the model's floor is unknown or the prompt clears it. `label` names the
255
- * caller (e.g. `"dream"`) so the warning points somewhere.
267
+ * caller (e.g. `"dream"`) so the warning points somewhere. Warns once per
268
+ * (label, model) per process.
256
269
  */
257
270
  export function warnIfBelowCacheMinimum(
258
271
  label: string,
@@ -263,6 +276,9 @@ export function warnIfBelowCacheMinimum(
263
276
  if (min === undefined) return;
264
277
  const estimated = estimateTokens(prompt);
265
278
  if (estimated >= min) return;
279
+ const key = `${label}\0${model}`;
280
+ if (warnedCacheMinimums.has(key)) return;
281
+ warnedCacheMinimums.add(key);
266
282
  logWarn(
267
283
  "agent",
268
284
  `[${label}] prompt ~${estimated} tokens is below ${model}'s ${min}-token ` +
@@ -23,6 +23,7 @@ import {
23
23
  import { classify } from "../../core/errors.js";
24
24
  import type { ChatRunParams } from "../../core/agent-runtime/capabilities.js";
25
25
  import type { QueryParams, QueryResult } from "./handler-types.js";
26
+ import { buildResultEvents } from "./result-events.js";
26
27
 
27
28
  const SENTINEL = Symbol("handler-to-events:sentinel");
28
29
 
@@ -205,21 +206,15 @@ export async function* handlerToEvents(
205
206
  return;
206
207
  }
207
208
 
208
- const usage = {
209
- inputTokens: result.inputTokens,
210
- outputTokens: result.outputTokens,
211
- cacheRead: result.cacheRead,
212
- cacheWrite: result.cacheWrite,
213
- modelId: params.model.id,
214
- };
215
- yield { type: "usage", usage };
216
- yield {
217
- type: "completed",
218
- result: {
219
- text: result.text,
220
- durationMs: result.durationMs,
221
- usage,
222
- modelId: params.model.id,
209
+ yield* buildResultEvents({
210
+ text: result.text,
211
+ durationMs: result.durationMs,
212
+ usage: {
213
+ inputTokens: result.inputTokens,
214
+ outputTokens: result.outputTokens,
215
+ cacheRead: result.cacheRead,
216
+ cacheWrite: result.cacheWrite,
223
217
  },
224
- };
218
+ modelId: params.model.id,
219
+ });
225
220
  }
@@ -24,6 +24,8 @@
24
24
  * - `stream-state` — backend-agnostic accumulator for stream loops.
25
25
  * - `turn-interrupt` — user-driven mid-turn interrupt registry (the
26
26
  * shared `ChatBackend.interruptChatTurn` for callback backends).
27
+ * - `turn-phases` — the post-stream phases (accounting, session name,
28
+ * trailing-prose contract, result tail) every handler runs.
27
29
  *
28
30
  * What's NOT here (intentionally):
29
31
  * - SDK-specific event types — those live in each backend.
@@ -34,11 +36,6 @@
34
36
 
35
37
  export { captureDeliveredText } from "./delivered-text.js";
36
38
 
37
- export {
38
- FLOW_VIOLATION_MAX_RETRIES,
39
- detectFlowViolation,
40
- } from "./flow-violation.js";
41
-
42
39
  export { registerTurnInterrupt } from "./turn-interrupt.js";
43
40
 
44
41
  export { formatUserPrompt } from "./prompt-format.js";
@@ -49,8 +46,6 @@ export {
49
46
  buildFirstTurnReminder,
50
47
  } from "./delivery-contract.js";
51
48
 
52
- export { extractSessionName } from "../../util/session-name.js";
53
-
54
49
  export { summarizeUsage } from "./usage.js";
55
50
 
56
51
  // Only what is consumed THROUGH the barrel. Everything else in
@@ -66,8 +61,6 @@ export {
66
61
 
67
62
  export { prepareSystemPrompt, appendBackendSuffix } from "./system-prompt.js";
68
63
 
69
- export { classifyRetry } from "./model-retry.js";
70
-
71
64
  export {
72
65
  createStreamState,
73
66
  appendText,
@@ -88,11 +81,17 @@ export {
88
81
 
89
82
  export { sleep } from "./sleep.js";
90
83
 
91
- export {
92
- recordToolCall,
93
- recordTurnMetrics,
94
- recordFailedTurnAccounting,
95
- recordFlowViolation,
96
- } from "./metrics.js";
84
+ export { recordToolCall } from "./metrics.js";
97
85
 
98
86
  export { applyRetryDecision } from "./handle-retry.js";
87
+
88
+ export {
89
+ accountTurn,
90
+ accountFailedTurn,
91
+ nameSessionFromFirstMessage,
92
+ enforceTrailingProse,
93
+ finishCallbackTurn,
94
+ turnUsageSnapshot,
95
+ } from "./turn-phases.js";
96
+
97
+ export { buildResultEvents } from "./result-events.js";
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The `usage` + `completed` pair that closes every successful
3
+ * `runChatTurn` stream, whether the backend emits events natively
4
+ * (Claude SDK) or through `handlerToEvents`. Kept free of storage and
5
+ * logging imports so the adapter stays a pure event translator.
6
+ */
7
+
8
+ import type { AgentEvent } from "../../core/agent-runtime/events.js";
9
+ import type { TokenUsageSnapshot } from "./usage.js";
10
+
11
+ export function buildResultEvents(inputs: {
12
+ text: string;
13
+ durationMs: number;
14
+ usage: TokenUsageSnapshot;
15
+ modelId: string;
16
+ }): [AgentEvent, AgentEvent] {
17
+ const usage = { ...inputs.usage, modelId: inputs.modelId };
18
+ return [
19
+ { type: "usage", usage },
20
+ {
21
+ type: "completed",
22
+ result: {
23
+ text: inputs.text,
24
+ durationMs: inputs.durationMs,
25
+ usage,
26
+ modelId: inputs.modelId,
27
+ },
28
+ },
29
+ ];
30
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The phases every chat turn runs once its SDK stream loop has ended.
3
+ *
4
+ * Each backend handler owns its options build, its `for await` loop and
5
+ * its event translation — everything that depends on the SDK's shape.
6
+ * What comes after is the same four steps in every backend:
7
+ *
8
+ * 1. `accountTurn` — per-turn metrics, session id, session usage
9
+ * (or `accountFailedTurn` on the terminal-failure path).
10
+ * 2. `nameSessionFromFirstMessage` — the session title.
11
+ * 3. `enforceTrailingProse` — the tool-only delivery contract and the
12
+ * flow-violation re-prompt decision.
13
+ * 4. `finishCallbackTurn` — the delivery summary log lines and the
14
+ * `QueryResult` (the event-stream tail is `result-events.ts`).
15
+ *
16
+ * The functions take the stream state and explicit config bits and return
17
+ * explicit results; the handler decides what to do with a retry decision
18
+ * because recursion is the handler's own entry point.
19
+ */
20
+
21
+ import {
22
+ getSession,
23
+ recordUsage,
24
+ setSessionId,
25
+ setSessionName,
26
+ } from "../../storage/sessions.js";
27
+ import { log } from "../../util/log.js";
28
+ import { extractSessionName } from "../../util/session-name.js";
29
+ import { traceMessage } from "../../util/trace.js";
30
+ import {
31
+ FLOW_VIOLATION_MAX_RETRIES,
32
+ detectFlowViolation,
33
+ type FlowViolationResult,
34
+ } from "./flow-violation.js";
35
+ import type { QueryResult } from "./handler-types.js";
36
+ import {
37
+ recordFailedTurnAccounting,
38
+ recordFlowViolation,
39
+ recordTurnMetrics,
40
+ } from "./metrics.js";
41
+ import type { StreamState } from "./stream-state.js";
42
+ import { summarizeUsage, type TokenUsageSnapshot } from "./usage.js";
43
+
44
+ // ── Usage snapshot ──────────────────────────────────────────────────────────
45
+
46
+ /** The slice of a stream state the post-loop phases read. */
47
+ export type TurnUsageState = Pick<
48
+ StreamState,
49
+ | "sdkInputTokens"
50
+ | "sdkOutputTokens"
51
+ | "sdkCacheRead"
52
+ | "sdkCacheWrite"
53
+ | "toolCalls"
54
+ | "numApiCalls"
55
+ | "contextTokens"
56
+ | "contextWindow"
57
+ >;
58
+
59
+ /** The turn's token totals as the metrics + session layers consume them. */
60
+ export function turnUsageSnapshot(state: TurnUsageState): TokenUsageSnapshot {
61
+ return {
62
+ inputTokens: state.sdkInputTokens,
63
+ outputTokens: state.sdkOutputTokens,
64
+ cacheRead: state.sdkCacheRead,
65
+ cacheWrite: state.sdkCacheWrite,
66
+ };
67
+ }
68
+
69
+ // ── Phase 1: accounting ─────────────────────────────────────────────────────
70
+
71
+ /** Context-fill fields persisted alongside the token totals. */
72
+ type TurnContextUsage = {
73
+ contextTokens?: number;
74
+ contextWindow?: number;
75
+ numApiCalls?: number;
76
+ costUsd?: number;
77
+ };
78
+
79
+ export type AccountTurnInputs = {
80
+ chatId: string;
81
+ /** Backend id — the `backend.<id>.*` metric dimension. */
82
+ backend: string;
83
+ state: TurnUsageState;
84
+ durationMs: number;
85
+ model: string;
86
+ /** Provider session id to persist; skipped when absent or unchanged. */
87
+ sessionId?: string;
88
+ /** True when the turn ended in a delivered failure (Codex `turn.failed`). */
89
+ failed?: boolean;
90
+ /** Override for backends that count tool calls outside the stream state. */
91
+ toolCalls?: number;
92
+ /**
93
+ * Context-fill fields for `recordUsage`. Only backends whose stream
94
+ * reports them pass this; the others leave the session's context
95
+ * display untouched (zeroed) as they always have.
96
+ */
97
+ context?: TurnContextUsage;
98
+ };
99
+
100
+ /**
101
+ * Record the per-turn metric rollup, persist the provider session id and
102
+ * fold the turn's usage into the session. Each write is independent, so
103
+ * the order here is not load-bearing.
104
+ */
105
+ export function accountTurn(inputs: AccountTurnInputs): void {
106
+ const { chatId, state, durationMs } = inputs;
107
+ const usage = turnUsageSnapshot(state);
108
+ recordTurnMetrics({
109
+ chatId,
110
+ backend: inputs.backend,
111
+ durationMs,
112
+ toolCalls: inputs.toolCalls ?? state.toolCalls,
113
+ apiCalls: state.numApiCalls,
114
+ ...(inputs.failed !== undefined ? { failed: inputs.failed } : {}),
115
+ usage,
116
+ });
117
+ persistSessionId(chatId, inputs.sessionId);
118
+ recordUsage(chatId, {
119
+ ...usage,
120
+ durationMs,
121
+ model: inputs.model,
122
+ ...inputs.context,
123
+ });
124
+ }
125
+
126
+ function persistSessionId(chatId: string, sessionId: string | undefined): void {
127
+ if (!sessionId) return;
128
+ if (getSession(chatId).sessionId === sessionId) return;
129
+ setSessionId(chatId, sessionId);
130
+ }
131
+
132
+ export type AccountFailedTurnInputs = {
133
+ chatId: string;
134
+ backend: string;
135
+ state: TurnUsageState;
136
+ durationMs: number;
137
+ model: string;
138
+ /** Overrides for backends that count outside the stream state. */
139
+ toolCalls?: number;
140
+ apiCalls?: number;
141
+ usage?: TokenUsageSnapshot;
142
+ };
143
+
144
+ /**
145
+ * Terminal-failure accounting from the stream state — the tokens a
146
+ * failed turn burned still count. Not for retry paths: the recursive
147
+ * attempt accounts for itself.
148
+ */
149
+ export function accountFailedTurn(inputs: AccountFailedTurnInputs): void {
150
+ const { state } = inputs;
151
+ recordFailedTurnAccounting({
152
+ backend: inputs.backend,
153
+ chatId: inputs.chatId,
154
+ durationMs: inputs.durationMs,
155
+ toolCalls: inputs.toolCalls ?? state.toolCalls,
156
+ apiCalls: inputs.apiCalls ?? state.numApiCalls,
157
+ model: inputs.model,
158
+ usage: inputs.usage ?? turnUsageSnapshot(state),
159
+ contextTokens: state.contextTokens,
160
+ contextWindow: state.contextWindow,
161
+ });
162
+ }
163
+
164
+ // ── Phase 2: session name ───────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Title the session from the user's first message. Skipped on retries,
168
+ * whose `text` is a synthetic reminder rather than what the user said.
169
+ */
170
+ export function nameSessionFromFirstMessage(inputs: {
171
+ chatId: string;
172
+ text: string;
173
+ previousTurns: number;
174
+ isRetry?: boolean;
175
+ }): void {
176
+ if (inputs.previousTurns !== 0 || inputs.isRetry) return;
177
+ const name = extractSessionName(inputs.text);
178
+ if (name) setSessionName(inputs.chatId, name);
179
+ }
180
+
181
+ // ── Phase 3: trailing-prose contract ────────────────────────────────────────
182
+
183
+ export type TrailingProseInputs = {
184
+ chatId: string;
185
+ state: Pick<
186
+ StreamState,
187
+ "lastTrailingText" | "turnTerminated" | "deliveredTextNorms" | "toolCalls"
188
+ >;
189
+ /** Synthetic flow-violation retries already spent on this message. */
190
+ flowRetries: number;
191
+ /** Frontend-aware reminder; omit for the default telegram-shaped text. */
192
+ reminder?: string;
193
+ };
194
+
195
+ /**
196
+ * Apply the tool-only delivery contract to a finished turn: detect a
197
+ * flow violation, count it, log it, and say whether the handler should
198
+ * re-prompt with `reminder`. Callers gate this on the contract actually
199
+ * being in force (a messaging frontend with delivery tools registered).
200
+ */
201
+ export function enforceTrailingProse(
202
+ inputs: TrailingProseInputs,
203
+ ): FlowViolationResult {
204
+ const { chatId, state, flowRetries } = inputs;
205
+ const violation = detectFlowViolation({
206
+ trailingText: state.lastTrailingText,
207
+ turnTerminated: state.turnTerminated,
208
+ deliveredTextNorms: state.deliveredTextNorms,
209
+ toolCalls: state.toolCalls,
210
+ retried: flowRetries > 0,
211
+ retryCount: flowRetries,
212
+ maxRetries: FLOW_VIOLATION_MAX_RETRIES,
213
+ ...(inputs.reminder !== undefined ? { reminder: inputs.reminder } : {}),
214
+ });
215
+ if (!violation.violated) return violation;
216
+
217
+ recordFlowViolation(
218
+ chatId,
219
+ violation.shouldRetry ? "retried" : "cap_exhausted",
220
+ );
221
+ log(
222
+ "agent",
223
+ `[${chatId}] flow violation: ${violation.reason}. ${
224
+ violation.shouldRetry
225
+ ? "Re-prompting with reminder."
226
+ : `Retry cap (${FLOW_VIOLATION_MAX_RETRIES}) exhausted — accepting silent drop.`
227
+ }`,
228
+ );
229
+ return violation;
230
+ }
231
+
232
+ // ── Phase 4: result ─────────────────────────────────────────────────────────
233
+
234
+ export type FinishCallbackTurnInputs = {
235
+ chatId: string;
236
+ state: TurnUsageState &
237
+ Pick<StreamState, "turnTerminated" | "deliveredTextNorms">;
238
+ responseText: string;
239
+ durationMs: number;
240
+ setupMs: number;
241
+ turnMs: number;
242
+ /** `routeDelivery`'s decision, or a backend's own route (`silent`). */
243
+ delivery: { route: string; chars: number };
244
+ /** Extra `key=value` diagnostics appended to the summary line. */
245
+ detail?: string;
246
+ };
247
+
248
+ /**
249
+ * The end-of-turn log lines and trace for a callback-shaped handler, and
250
+ * the `QueryResult` it returns.
251
+ */
252
+ export function finishCallbackTurn(
253
+ inputs: FinishCallbackTurnInputs,
254
+ ): QueryResult {
255
+ const { chatId, state, responseText, durationMs, delivery } = inputs;
256
+ const usage = turnUsageSnapshot(state);
257
+ log(
258
+ "agent",
259
+ `[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
260
+ );
261
+ log(
262
+ "agent",
263
+ `[${chatId}] -> (${summarizeUsage(usage, {
264
+ durationMs,
265
+ toolCalls: state.toolCalls,
266
+ })} terminator=${state.turnTerminated ? "yes" : "no"} ` +
267
+ `delivered=${state.deliveredTextNorms.length} ` +
268
+ `respLen=${responseText.length} ` +
269
+ `setup=${inputs.setupMs}ms turn=${inputs.turnMs}ms` +
270
+ `${inputs.detail ? ` ${inputs.detail}` : ""})`,
271
+ );
272
+ traceMessage(chatId, "out", responseText, {
273
+ durationMs,
274
+ toolCalls: state.toolCalls,
275
+ });
276
+ return { text: responseText, durationMs, ...usage };
277
+ }
package/src/bootstrap.ts CHANGED
@@ -28,6 +28,7 @@ import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
28
28
  import { initCron } from "./core/background/cron.js";
29
29
  import { initPlanAlerts } from "./core/background/plan-alerts.js";
30
30
  import { setAdminNotifier } from "./core/notify.js";
31
+ import { startAuthExpiryMonitor } from "./core/auth/expiry-monitor.js";
31
32
  import {
32
33
  initTriggers,
33
34
  resumeAfterRestart as resumeTriggersAfterRestart,
@@ -486,16 +487,7 @@ export async function initBackendAndDispatcher(
486
487
  // first consumer is WhatsApp pairing: codes must travel over a LIVE
487
488
  // frontend, not the dead one's log). Same delivery route as the plan
488
489
  // alerts above.
489
- if (config.adminUserId) {
490
- const adminChatId = config.adminUserId;
491
- setAdminNotifier(async (text: string) =>
492
- resolveFrontendByNumericId(
493
- adminChatId,
494
- String(adminChatId),
495
- frontends,
496
- ).sendMessage(adminChatId, text),
497
- );
498
- }
490
+ wireAdminNotifier(config, frontends);
499
491
 
500
492
  // Soul — initialize the identity kernel singleton from config so the prompt
501
493
  // injection / dream hooks see the right enabled state. Off by default; a
@@ -586,3 +578,25 @@ export async function initBackendAndDispatcher(
586
578
 
587
579
  return { backend };
588
580
  }
581
+
582
+ /**
583
+ * Wire the admin notification seam to the admin's frontend, and start the
584
+ * login-expiry monitor that rides it: the CLIs' "N days to log in again"
585
+ * banner, delivered to the admin instead of a terminal nobody is watching
586
+ * (/auth then completes the sign-in from the chat).
587
+ */
588
+ function wireAdminNotifier(
589
+ config: TalonConfig,
590
+ frontends: Parameters<typeof resolveFrontendByNumericId>[2],
591
+ ): void {
592
+ if (!config.adminUserId) return;
593
+ const adminChatId = config.adminUserId;
594
+ setAdminNotifier(async (text: string) =>
595
+ resolveFrontendByNumericId(
596
+ adminChatId,
597
+ String(adminChatId),
598
+ frontends,
599
+ ).sendMessage(adminChatId, text),
600
+ );
601
+ startAuthExpiryMonitor();
602
+ }