tomo-ai 0.8.2 → 0.8.3

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 (96) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +7 -1
  3. package/defaults/skills/system/CONFIG.md +3 -1
  4. package/defaults/skills/system/SKILL.md +3 -0
  5. package/dist/agent/live-session.d.ts +72 -1
  6. package/dist/agent/live-session.d.ts.map +1 -1
  7. package/dist/agent/live-session.js +282 -50
  8. package/dist/agent/live-session.js.map +1 -1
  9. package/dist/agent/sdk-options.d.ts +6 -0
  10. package/dist/agent/sdk-options.d.ts.map +1 -1
  11. package/dist/agent/sdk-options.js +8 -1
  12. package/dist/agent/sdk-options.js.map +1 -1
  13. package/dist/agent.d.ts +15 -11
  14. package/dist/agent.d.ts.map +1 -1
  15. package/dist/agent.js +194 -148
  16. package/dist/agent.js.map +1 -1
  17. package/dist/channels/attachments.d.ts +8 -0
  18. package/dist/channels/attachments.d.ts.map +1 -0
  19. package/dist/channels/attachments.js +46 -0
  20. package/dist/channels/attachments.js.map +1 -0
  21. package/dist/channels/imessage.d.ts +0 -1
  22. package/dist/channels/imessage.d.ts.map +1 -1
  23. package/dist/channels/imessage.js +48 -81
  24. package/dist/channels/imessage.js.map +1 -1
  25. package/dist/channels/telegram.d.ts.map +1 -1
  26. package/dist/channels/telegram.js +106 -76
  27. package/dist/channels/telegram.js.map +1 -1
  28. package/dist/channels/text-utils.d.ts +7 -0
  29. package/dist/channels/text-utils.d.ts.map +1 -0
  30. package/dist/channels/text-utils.js +27 -0
  31. package/dist/channels/text-utils.js.map +1 -0
  32. package/dist/cli/backup.js +24 -9
  33. package/dist/cli/backup.js.map +1 -1
  34. package/dist/cli/config/shared.d.ts.map +1 -1
  35. package/dist/cli/config/shared.js +4 -5
  36. package/dist/cli/config/shared.js.map +1 -1
  37. package/dist/cli/init.d.ts.map +1 -1
  38. package/dist/cli/init.js +4 -4
  39. package/dist/cli/init.js.map +1 -1
  40. package/dist/cli/start.js +20 -1
  41. package/dist/cli/start.js.map +1 -1
  42. package/dist/config.d.ts +10 -0
  43. package/dist/config.d.ts.map +1 -1
  44. package/dist/config.js +11 -4
  45. package/dist/config.js.map +1 -1
  46. package/dist/cron/scheduler.d.ts +1 -0
  47. package/dist/cron/scheduler.d.ts.map +1 -1
  48. package/dist/cron/scheduler.js +22 -5
  49. package/dist/cron/scheduler.js.map +1 -1
  50. package/dist/cron/store.d.ts.map +1 -1
  51. package/dist/cron/store.js +8 -2
  52. package/dist/cron/store.js.map +1 -1
  53. package/dist/fs-utils.d.ts +6 -0
  54. package/dist/fs-utils.d.ts.map +1 -0
  55. package/dist/fs-utils.js +43 -0
  56. package/dist/fs-utils.js.map +1 -0
  57. package/dist/jsonl.d.ts +3 -0
  58. package/dist/jsonl.d.ts.map +1 -0
  59. package/dist/jsonl.js +21 -0
  60. package/dist/jsonl.js.map +1 -0
  61. package/dist/lcm/blocks.d.ts +0 -7
  62. package/dist/lcm/blocks.d.ts.map +1 -1
  63. package/dist/lcm/blocks.js +3 -31
  64. package/dist/lcm/blocks.js.map +1 -1
  65. package/dist/lcm/compact.d.ts +35 -0
  66. package/dist/lcm/compact.d.ts.map +1 -1
  67. package/dist/lcm/compact.js +14 -26
  68. package/dist/lcm/compact.js.map +1 -1
  69. package/dist/lcm/prune-tools.d.ts +5 -0
  70. package/dist/lcm/prune-tools.d.ts.map +1 -1
  71. package/dist/lcm/prune-tools.js +100 -32
  72. package/dist/lcm/prune-tools.js.map +1 -1
  73. package/dist/lcm/runner.d.ts.map +1 -1
  74. package/dist/lcm/runner.js +2 -1
  75. package/dist/lcm/runner.js.map +1 -1
  76. package/dist/lcm/stats.d.ts.map +1 -1
  77. package/dist/lcm/stats.js +6 -23
  78. package/dist/lcm/stats.js.map +1 -1
  79. package/dist/mcp/oauth.d.ts.map +1 -1
  80. package/dist/mcp/oauth.js +7 -3
  81. package/dist/mcp/oauth.js.map +1 -1
  82. package/dist/mcp/pet-store.d.ts.map +1 -1
  83. package/dist/mcp/pet-store.js +5 -1
  84. package/dist/mcp/pet-store.js.map +1 -1
  85. package/dist/sessions/keys.d.ts +20 -0
  86. package/dist/sessions/keys.d.ts.map +1 -0
  87. package/dist/sessions/keys.js +45 -0
  88. package/dist/sessions/keys.js.map +1 -0
  89. package/dist/sessions/repair.d.ts.map +1 -1
  90. package/dist/sessions/repair.js +3 -2
  91. package/dist/sessions/repair.js.map +1 -1
  92. package/dist/sessions/store.d.ts +1 -1
  93. package/dist/sessions/store.d.ts.map +1 -1
  94. package/dist/sessions/store.js +29 -26
  95. package/dist/sessions/store.js.map +1 -1
  96. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,6 +1,31 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.8.3 (2026-06-11)
4
+
5
+ ### Features
6
+
7
+ - **Experimental message steering** (#142). New `steering` config (`TOMO_STEERING=true`, default off) lets user messages that arrive during an in-flight tool-using turn bypass the per-session queue and inject at the next tool-call boundary. If the current turn has no boundary left, the message runs as the next follow-up turn. Cron, continuity, and other system-originated turns continue to queue normally.
8
+
9
+ ### Bug fixes
10
+
11
+ - **Six high-severity fixes from a codebase review** (#140):
12
+ - Fresh installs no longer crash on every CLI command (including `tomo init` and `--help`) when no channel is configured — channel validation moved from config load to daemon startup, and `tomo start` now validates before spawning the background child instead of printing "started" while the daemon silently dies.
13
+ - The iMessage webhook server binds `127.0.0.1` instead of all interfaces and caps request bodies at 1 MB; the webhook is also registered as the literal `127.0.0.1` (BlueBubbles could resolve `localhost` to `::1` and get connection refused), with stale `localhost`-form registrations from earlier versions cleaned up on upgrade.
14
+ - The cron scheduler no longer re-fires a due job on every 30s poll while a long agent run is still in flight (re-entrancy guard on `tick()`), and tick errors are caught instead of becoming unhandled rejections.
15
+ - Telegram streaming no longer silently drops content when a send/edit fails: progress is only advanced on success, the final flush retries with backoff, blocks over 4096 chars roll over into a new message, and `send()` chunks long text like iMessage does.
16
+ - `tomo backup restore` no longer deletes the preserved `.claude` directory along with the workspace tree; the backup's custom skills are merged in without overwriting live ones.
17
+ - The LCM compact nudge goes through the per-session queue instead of a fire-and-forget run that could overlap the next user message.
18
+ - **Multi-process race and atomic-write fixes** (#141). The daemon and CLI commands (`tomo cron add`, `tomo sessions clear`, `tomo config`, `tomo lcm prune-tools`) mutate the same files and could clobber each other:
19
+ - `SessionStore` registry mutators and list APIs reload from disk before acting, so CLI-side changes are no longer reverted by the daemon's next stale save.
20
+ - `CronStore.markRun`/`remove` reload before saving, so a job added while the daemon executes another job is no longer silently deleted.
21
+ - The MCP OAuth token store, pet store, and SDK session repair now write atomically (temp + rename) — a crash mid-write previously left truncated JSON that could permanently discard every MCP server's refresh token.
22
+ - `tomo lcm prune-tools` rewrites the live SDK JSONL with the same concurrent-append-safe machinery as compaction, instead of a plain read/write that could truncate events the SDK was appending mid-prune.
23
+
24
+ ### Other
25
+
26
+ - Raise minimum Node version to 22.12 (#139).
27
+ - Internal cleanup: extracted shared `fs-utils`, `jsonl`, and session-key helpers (#138).
28
+ - Bump `commander` 14.0.3 → 15.0.0 (#123).
4
29
 
5
30
  ## 0.8.2 (2026-06-08)
6
31
 
package/README.md CHANGED
@@ -31,7 +31,7 @@ That's it. Open Telegram and message your bot.
31
31
 
32
32
  ## Requirements
33
33
 
34
- - Node.js 22+
34
+ - Node.js 22.12+
35
35
  - [Claude Code](https://claude.com/claude-code) installed. Authenticate Claude Code for direct Claude models, or configure a LiteLLM gateway for non-Claude backends.
36
36
  - At least one channel:
37
37
  - **Telegram** — bot token from [@BotFather](https://t.me/BotFather)
@@ -225,6 +225,7 @@ Run `tomo config` for interactive setup, or edit `~/.tomo/config.json` directly:
225
225
  },
226
226
  "maxTurns": 50,
227
227
  "saveInboundImages": true,
228
+ "steering": false,
228
229
  "continuity": true,
229
230
  "continuityScript": {
230
231
  "path": "~/bin/tomo-continuity.sh",
@@ -252,6 +253,7 @@ Environment variables override config file values:
252
253
  | `TOMO_LITELLM_MODE` | Optional LiteLLM mode: `anthropic-compatible` or `chatgpt-subscription` |
253
254
  | `TOMO_WORKSPACE` | Override workspace directory |
254
255
  | `TOMO_MAX_TURNS` | Override per-turn tool-use ceiling (default: `50`) |
256
+ | `TOMO_STEERING` | Set `true` to steer mid-turn messages into the in-flight turn (experimental, default: `false`) |
255
257
  | `TOMO_CONTINUITY_SCRIPT` | Override the optional continuity script path |
256
258
  | `TOMO_CONTINUITY_SCRIPT_TIMEOUT_MS` | Override continuity script timeout (default: `30000`) |
257
259
  | `TOMO_CONTINUITY_SCRIPT_MAX_OUTPUT_CHARS` | Override continuity script stdout/stderr cap (default: `8000`) |
@@ -259,6 +261,10 @@ Environment variables override config file values:
259
261
 
260
262
  `continuityScript` can also be a simple path string, e.g. `"continuityScript": "~/bin/tomo-continuity.sh"`. Relative paths resolve under `~/.tomo`; the script runs once per scheduled heartbeat and manual `tomo continuity` trigger, and its stdout/stderr or failure status is appended to the normal continuity prompt.
261
263
 
264
+ ### Steering (experimental)
265
+
266
+ By default, a message you send while Tomo is mid-task waits in a queue until the current turn finishes. With `"steering": true`, messages sent during a long tool-using turn are injected into it at the next tool-call boundary — so "stop", "wait", or extra context reaches the model immediately instead of after the task completes. If the turn has no tool calls left, the message runs as its own follow-up turn right after. iMessage fragment settling still applies before injection; system-originated turns (cron, continuity) keep their normal queued behavior. This relies on the Claude Agent SDK's mid-turn message queue and is off by default.
267
+
262
268
  ### LiteLLM / ChatGPT Subscription Models
263
269
 
264
270
  Tomo still runs through Claude Agent SDK, but you can point the SDK at a local LiteLLM proxy and select a LiteLLM model name such as `chatgpt/gpt-5.5`. This keeps Tomo's Claude SDK sessions, memory, workspace, MCP tools, and LCM behavior while LiteLLM translates Anthropic `/v1/messages` streaming calls to the ChatGPT subscription backend.
@@ -49,6 +49,7 @@ Before direct edits, copy `~/.tomo/config.json` to `~/.tomo/config.json.bak`. Ch
49
49
  "apiKey": "sk-tomo-local"
50
50
  },
51
51
  "maxTurns": 50,
52
+ "steering": false,
52
53
  "mcpServers": {
53
54
  "github-copilot": {
54
55
  "type": "http",
@@ -96,6 +97,7 @@ Before direct edits, copy `~/.tomo/config.json` to `~/.tomo/config.json.bak`. Ch
96
97
  | `litellm.baseUrl` | string | Optional LiteLLM proxy base URL, e.g. `http://localhost:4000`. When set, Tomo still uses Claude Agent SDK but sends SDK model calls to the proxy via `ANTHROPIC_BASE_URL`. |
97
98
  | `litellm.apiKey` | string | LiteLLM proxy key sent as `ANTHROPIC_API_KEY`. This is the proxy key, not an Anthropic key. For ChatGPT subscription models, LiteLLM owns the OAuth device flow and token storage. |
98
99
  | `maxTurns` | number | Max agent turns per single user message (one turn ≈ one tool-use round). Default `50`. Raise if you see "max turns exceeded" on long tool chains. |
100
+ | `steering` | boolean | Experimental. Default `false`. When `true`, user messages that arrive while a turn is in flight bypass the per-session queue and are injected at the next tool-call boundary. If the current turn has no boundary left, the message runs as a follow-up turn. Cron, continuity, and other system-originated turns still queue normally. Also settable with `TOMO_STEERING=true`. |
99
101
  | `mcpServers` | object | External MCP servers keyed by server name. Supports stdio (`command`, `args`, `env`), HTTP (`type: "http"`, `url`, `headers`), and SSE (`type: "sse"`, `url`, `headers`). Environment variables like `${HOME}` expand in `url`, `headers`, `env`, and `args`. |
100
102
  | `mcpServers.<name>.oauth` | object | Optional harness-managed OAuth for HTTP/SSE MCP servers. Supports `authorizationServer` (optional; omitted = discover from the MCP server), `clientId` (optional if dynamic registration is available), `scopes`, `tokenStoreKey`, `redirectUri`, and `clientName`. Tokens are stored outside agent context in `workspace/secrets/mcp-oauth.json` (`0600`). |
101
103
  | `mcpAllowedTools` | string[] | Auto-allowed external MCP tools. If omitted, Tomo defaults to `mcp__<server>__*` for every configured external server. Set this to narrow tool access, e.g. `["mcp__github__list_issues"]`. |
@@ -132,5 +134,5 @@ Start it with `litellm --config ~/litellm-chatgpt.yaml`, then set Tomo's model t
132
134
  ## Requirements and overrides
133
135
 
134
136
  - **At least one channel must be configured** — either `channels.telegram.token` or `channels.imessage.url`. Startup fails otherwise.
135
- - **Env vars override file values** where they exist: `TELEGRAM_BOT_TOKEN`, `IMESSAGE_URL`, `IMESSAGE_PASSWORD`, `IMESSAGE_WEBHOOK_PORT`, `CLAUDE_MODEL`, `TOMO_LITELLM_BASE_URL`, `TOMO_LITELLM_API_KEY`, `TOMO_LITELLM_MODE`, `TOMO_CITY`, `TOMO_CONTINUITY`, `TOMO_WORKSPACE`, `SESSIONS_DIR`, `HISTORY_LIMIT`, `TOMO_MAX_TURNS`.
137
+ - **Env vars override file values** where they exist: `TELEGRAM_BOT_TOKEN`, `IMESSAGE_URL`, `IMESSAGE_PASSWORD`, `IMESSAGE_WEBHOOK_PORT`, `CLAUDE_MODEL`, `TOMO_LITELLM_BASE_URL`, `TOMO_LITELLM_API_KEY`, `TOMO_LITELLM_MODE`, `TOMO_CITY`, `TOMO_CONTINUITY`, `TOMO_WORKSPACE`, `SESSIONS_DIR`, `HISTORY_LIMIT`, `TOMO_MAX_TURNS`, `TOMO_STEERING`.
136
138
  - `workspaceDir`, `sessionsDir`, `historyLimit` are env-only — they're not read from the JSON file.
@@ -72,6 +72,9 @@ Before changing `~/.tomo/config.json` directly, copy the current file to `~/.tom
72
72
  ### Streaming
73
73
  Responses stream to Telegram in real-time — messages update every 1.5s as tokens arrive.
74
74
 
75
+ ### Message steering
76
+ If `steering: true` is set in `~/.tomo/config.json`, user messages that arrive during a long tool-using turn are injected into the in-flight turn at the next tool-call boundary instead of waiting behind it. Use this to treat mid-task corrections like "stop", "wait", or added context as immediately relevant. If there is no remaining tool boundary, the message runs as the next follow-up turn. Cron, continuity, and other system-originated turns still queue normally.
77
+
75
78
  ### MEDIA: tag
76
79
  To send an image/file to the user, include `MEDIA:/path/to/file.png` in your response. The harness strips it from text and sends the file. Text before/after becomes the caption.
77
80
 
@@ -15,6 +15,15 @@ export interface MessageRequest {
15
15
  resolve: (response: string) => void;
16
16
  reject: (err: Error) => void;
17
17
  }
18
+ /**
19
+ * Sentinel resolution for a steered message that merged into the in-flight
20
+ * turn: that turn's owner request receives (and delivers) the combined
21
+ * response, so the steered caller gets this empty marker instead. The empty
22
+ * string is safe as a sentinel only because send()/steer() can never
23
+ * legitimately resolve with "" — empty turns fall back to "I'm not sure how
24
+ * to respond to that." in the result handler.
25
+ */
26
+ export declare const STEER_MERGED = "";
18
27
  export interface QueryResult {
19
28
  costUsd: number;
20
29
  inputTokens: number;
@@ -30,8 +39,13 @@ export interface QueryResult {
30
39
  }
31
40
  export declare class LiveSession {
32
41
  private q;
33
- private pendingMessage;
42
+ private inputQueue;
43
+ private inputWaiter;
34
44
  private currentRequest;
45
+ private mergedRequests;
46
+ private pendingSteers;
47
+ private promotedSteerText;
48
+ private idleWaiters;
35
49
  private parts;
36
50
  private streamingText;
37
51
  private sessionId;
@@ -45,8 +59,41 @@ export declare class LiveSession {
45
59
  private activeStreamBlockTypes;
46
60
  constructor(options: ReturnType<typeof sdkOptions>, sessionKey?: string, turnBudget?: TurnBudget);
47
61
  private messageGenerator;
62
+ private pushInput;
48
63
  private consumeEvents;
64
+ /**
65
+ * Reject the in-flight turn's owner plus any merged/pending steered
66
+ * requests, clear turn state, and wake idle waiters. No-op when idle.
67
+ */
68
+ private failTurn;
69
+ /** Resolves when no turn is in flight (or the session dies). */
70
+ waitForIdle(): Promise<void>;
71
+ private notifyIdle;
72
+ /**
73
+ * True while a turn is in flight — including steered messages the CLI may
74
+ * still run as a follow-up turn after the current one resolves.
75
+ */
76
+ isBusy(): boolean;
49
77
  private handleEvent;
78
+ /**
79
+ * Observability-only path for subagent events (parent_tool_use_id set):
80
+ * log tool activity so subagent work shows up in the logs, but never touch
81
+ * turn state (parts/streamingText) or channel-facing callbacks.
82
+ */
83
+ private logSubagentEvent;
84
+ private logToolResults;
85
+ /**
86
+ * Steered-message bookkeeping from the CLI's replay events (requires the
87
+ * --replay-user-messages flag, passed via extraArgs when config.steering
88
+ * is on; non-replay user events never carry steered text verbatim, so
89
+ * matching is restricted to isReplay events to avoid false positives).
90
+ * Two shapes appear:
91
+ * - A pending steer's text echoed mid-turn → the CLI injected it into
92
+ * the in-flight turn at a tool boundary; fold it into this turn.
93
+ * - The promoted steer's own text echoed at its turn's start → the CLI
94
+ * batched the remaining queued steers into that promoted turn.
95
+ */
96
+ private matchSteerEchoes;
50
97
  private logContextUsage;
51
98
  send(text: string, onText?: (text: string) => void, images?: Array<{
52
99
  data: string;
@@ -56,6 +103,30 @@ export declare class LiveSession {
56
103
  mediaType: string;
57
104
  filename?: string;
58
105
  }>): Promise<string>;
106
+ /**
107
+ * Inject a message into the in-flight turn instead of waiting behind it
108
+ * (config `steering`). The CLI delivers it at the next tool-call boundary;
109
+ * if the turn has no boundary left, the CLI queues it and runs it as the
110
+ * next turn. Resolution:
111
+ * - merged into the in-flight turn → resolves with STEER_MERGED; the
112
+ * turn's owner request streams and resolves the combined response.
113
+ * - ran as its own follow-up turn → resolves with that turn's response,
114
+ * streamed through the callbacks passed here.
115
+ * Falls back to a plain send() when no turn is in flight.
116
+ */
117
+ steer(text: string, onText?: (text: string) => void, images?: Array<{
118
+ data: string;
119
+ mediaType: string;
120
+ }>, onBlockComplete?: (text: string) => void | Promise<void>, documents?: Array<{
121
+ data: string;
122
+ mediaType: string;
123
+ filename?: string;
124
+ }>): Promise<string>;
125
+ /**
126
+ * Timeout path for a steered request: detach it from whichever slot it
127
+ * occupies; if it owns the in-flight turn, fail the whole turn.
128
+ */
129
+ private dropRequest;
59
130
  getSessionId(): string | null;
60
131
  isAlive(): boolean;
61
132
  close(): void;
@@ -1 +1 @@
1
- {"version":3,"file":"live-session.d.ts","sourceRoot":"","sources":["../../src/agent/live-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,KAAK,cAAc,EAAmB,MAAM,gCAAgC,CAAC;AAEzG,OAAO,EAAmB,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAErF,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,cAAc,CAAC;IACxB,0EAA0E;IAC1E,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,MAAM,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACvD;AAmDD,qBAAa,WAAW;IACtB,OAAO,CAAC,CAAC,CAAQ;IACjB,OAAO,CAAC,cAAc,CAAgD;IACtE,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,KAAK,CAAQ;IACrB,UAAU,EAAE,WAAW,GAAG,IAAI,CAAQ;IACtC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,UAAU,CAAqB;IACvC,OAAO,CAAC,UAAU,CAAyB;IAG3C,OAAO,CAAC,gBAAgB,CAA6B;IACrD,OAAO,CAAC,sBAAsB,CAA6B;gBAE/C,OAAO,EAAE,UAAU,CAAC,OAAO,UAAU,CAAC,EAAE,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,UAAU;YAOjF,gBAAgB;YAUjB,aAAa;YAeb,WAAW;YAyIX,eAAe;IA6CvB,IAAI,CACR,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,EAC/B,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,EACnD,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,EACxD,SAAS,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,GACxE,OAAO,CAAC,MAAM,CAAC;IA2DlB,YAAY,IAAI,MAAM,GAAG,IAAI;IAI7B,OAAO,IAAI,OAAO;IAIlB,KAAK,IAAI,IAAI;CAId"}
1
+ {"version":3,"file":"live-session.d.ts","sourceRoot":"","sources":["../../src/agent/live-session.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,16 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { log } from "../logger.js";
3
3
  import { resetTurnBudget } from "./sdk-options.js";
4
+ /**
5
+ * Sentinel resolution for a steered message that merged into the in-flight
6
+ * turn: that turn's owner request receives (and delivers) the combined
7
+ * response, so the steered caller gets this empty marker instead. The empty
8
+ * string is safe as a sentinel only because send()/steer() can never
9
+ * legitimately resolve with "" — empty turns fall back to "I'm not sure how
10
+ * to respond to that." in the result handler.
11
+ */
12
+ export const STEER_MERGED = "";
13
+ const TIMEOUT_MS = 10 * 60 * 1000; // 10 minute timeout per send()/steer()
4
14
  function summarizeToolResult(content) {
5
15
  // Tool results arrive as either a string or an array of content blocks
6
16
  // ({type:"text",text:"..."} | {type:"image",...} | etc.). We flatten to a
@@ -49,10 +59,54 @@ function isTextBlock(block) {
49
59
  function isToolUseBlock(block) {
50
60
  return isObject(block) && block.type === "tool_use" && typeof block.name === "string";
51
61
  }
62
+ function buildContentBlocks(text, images, documents) {
63
+ const content = [];
64
+ if (images && images.length > 0) {
65
+ for (const img of images) {
66
+ content.push({
67
+ type: "image",
68
+ source: { type: "base64", media_type: img.mediaType, data: img.data },
69
+ });
70
+ }
71
+ }
72
+ if (documents && documents.length > 0) {
73
+ for (const doc of documents) {
74
+ const block = {
75
+ type: "document",
76
+ source: { type: "base64", media_type: doc.mediaType, data: doc.data },
77
+ };
78
+ if (doc.filename)
79
+ block.title = doc.filename;
80
+ content.push(block);
81
+ }
82
+ }
83
+ content.push({ type: "text", text });
84
+ return content;
85
+ }
52
86
  export class LiveSession {
53
87
  q;
54
- pendingMessage = null;
88
+ // Input messages waiting for the SDK to pull them through messageGenerator.
89
+ // The SDK pumps this eagerly (writes to the CLI as soon as we yield), which
90
+ // is what lets steer() inject a message while a turn is in flight.
91
+ inputQueue = [];
92
+ inputWaiter = null;
93
+ // The request that owns the in-flight turn: its callbacks receive the
94
+ // stream and it resolves with the turn's full response.
55
95
  currentRequest = null;
96
+ // Steered requests confirmed merged into the in-flight turn (we saw the CLI
97
+ // echo their message back mid-turn). Resolved with STEER_MERGED at result.
98
+ mergedRequests = [];
99
+ // Steered requests injected but not yet seen in the event stream. If they
100
+ // miss the in-flight turn's tool boundaries, the CLI queues them and runs
101
+ // them as the next turn (promoted at result time).
102
+ pendingSteers = [];
103
+ // Text of the steered request promoted to own the current turn, if any.
104
+ // Used to detect the CLI batching the remaining queued steers into that
105
+ // turn (see matchSteerEchoes).
106
+ promotedSteerText = null;
107
+ // send() callers waiting for the session to go idle (steered messages can
108
+ // keep the session busy past the turn the agent-level queue saw finish).
109
+ idleWaiters = [];
56
110
  parts = [];
57
111
  streamingText = "";
58
112
  sessionId = null;
@@ -74,13 +128,19 @@ export class LiveSession {
74
128
  }
75
129
  async *messageGenerator() {
76
130
  while (this.alive) {
77
- const msg = await new Promise((resolve) => {
78
- this.pendingMessage = resolve;
79
- });
80
- this.pendingMessage = null;
81
- yield msg;
131
+ while (this.inputQueue.length === 0) {
132
+ if (!this.alive)
133
+ return;
134
+ await new Promise((resolve) => { this.inputWaiter = resolve; });
135
+ this.inputWaiter = null;
136
+ }
137
+ yield this.inputQueue.shift();
82
138
  }
83
139
  }
140
+ pushInput(msg) {
141
+ this.inputQueue.push(msg);
142
+ this.inputWaiter?.();
143
+ }
84
144
  async consumeEvents() {
85
145
  try {
86
146
  for await (const event of this.q) {
@@ -88,15 +148,65 @@ export class LiveSession {
88
148
  }
89
149
  }
90
150
  catch (err) {
91
- // If there's a pending request, reject it
92
- if (this.currentRequest) {
93
- this.currentRequest.reject(err instanceof Error ? err : new Error(String(err)));
94
- this.currentRequest = null;
95
- }
151
+ this.failTurn(err instanceof Error ? err : new Error(String(err)));
96
152
  }
97
153
  this.alive = false;
154
+ this.inputWaiter?.();
155
+ // Reject anything still in flight so callers fail fast instead of
156
+ // hanging until their timeout, and wake idle waiters so a queued send()
157
+ // can observe the dead session.
158
+ this.failTurn(new Error("Session is closed"));
159
+ }
160
+ /**
161
+ * Reject the in-flight turn's owner plus any merged/pending steered
162
+ * requests, clear turn state, and wake idle waiters. No-op when idle.
163
+ */
164
+ failTurn(err) {
165
+ const requests = [];
166
+ if (this.currentRequest)
167
+ requests.push(this.currentRequest);
168
+ requests.push(...this.mergedRequests, ...this.pendingSteers.map((e) => e.req));
169
+ this.currentRequest = null;
170
+ this.mergedRequests = [];
171
+ this.pendingSteers = [];
172
+ this.promotedSteerText = null;
173
+ for (const r of requests)
174
+ r.reject(err);
175
+ this.notifyIdle();
176
+ }
177
+ /** Resolves when no turn is in flight (or the session dies). */
178
+ async waitForIdle() {
179
+ while (this.alive && this.isBusy()) {
180
+ await new Promise((resolve) => this.idleWaiters.push(resolve));
181
+ }
182
+ }
183
+ notifyIdle() {
184
+ const waiters = this.idleWaiters;
185
+ this.idleWaiters = [];
186
+ for (const w of waiters)
187
+ w();
188
+ }
189
+ /**
190
+ * True while a turn is in flight — including steered messages the CLI may
191
+ * still run as a follow-up turn after the current one resolves.
192
+ */
193
+ isBusy() {
194
+ return this.currentRequest !== null || this.mergedRequests.length > 0 || this.pendingSteers.length > 0;
98
195
  }
99
196
  async handleEvent(event) {
197
+ // Events originating inside a subagent (Agent tool) carry
198
+ // parent_tool_use_id. They must never reach the channel-facing callbacks:
199
+ // the subagent's interim narration would ship to the user as Tomo's own
200
+ // words mid-turn, and its text would pollute `parts` (so it could also
201
+ // surface in the final response — or be silently dropped, depending on
202
+ // whether a request happened to be in flight when the block arrived).
203
+ // The subagent's outcome reaches the user when the main agent folds the
204
+ // Agent tool result into its own reply.
205
+ const parentToolUseId = event.parent_tool_use_id ?? null;
206
+ if (parentToolUseId) {
207
+ this.logSubagentEvent(event);
208
+ return;
209
+ }
100
210
  const req = this.currentRequest;
101
211
  if (event.type === "stream_event") {
102
212
  const se = event;
@@ -146,16 +256,11 @@ export class LiveSession {
146
256
  }
147
257
  }
148
258
  }
259
+ if (event.type === "user" && event.message?.content && event.isReplay === true) {
260
+ this.matchSteerEchoes(event.message.content);
261
+ }
149
262
  if (event.type === "user" && event.message?.content && Array.isArray(event.message.content)) {
150
- for (const block of event.message.content) {
151
- if (block && typeof block === "object" && "type" in block && block.type === "tool_result") {
152
- const tr = block;
153
- const name = tr.tool_use_id ? this.pendingToolNames.get(tr.tool_use_id) : undefined;
154
- if (tr.tool_use_id)
155
- this.pendingToolNames.delete(tr.tool_use_id);
156
- log.info({ tool: name ?? "?", is_error: tr.is_error ?? false }, `result: ${summarizeToolResult(tr.content)}`);
157
- }
158
- }
263
+ this.logToolResults(event.message.content, false);
159
264
  }
160
265
  if (event.type === "system" && event.subtype === "compact_boundary") {
161
266
  const compact = event;
@@ -200,7 +305,95 @@ export class LiveSession {
200
305
  this.streamingText = "";
201
306
  this.activeStreamBlockTypes.clear();
202
307
  req?.resolve(response);
308
+ for (const m of this.mergedRequests)
309
+ m.resolve(STEER_MERGED);
310
+ this.mergedRequests = [];
203
311
  this.currentRequest = null;
312
+ this.promotedSteerText = null;
313
+ if (this.pendingSteers.length > 0) {
314
+ // Steered messages that missed this turn's tool boundaries were
315
+ // queued by the CLI and run next. Promote only the FIRST as the
316
+ // next turn's owner (its callbacks receive the stream); the rest
317
+ // stay pending — if the CLI batches them into the promoted turn we
318
+ // detect it via replay echoes (see matchSteerEchoes), otherwise
319
+ // they're promoted in order by subsequent results.
320
+ const [first, ...rest] = this.pendingSteers;
321
+ this.pendingSteers = rest;
322
+ this.currentRequest = first.req;
323
+ this.promotedSteerText = first.text;
324
+ }
325
+ else {
326
+ this.notifyIdle();
327
+ }
328
+ }
329
+ }
330
+ /**
331
+ * Observability-only path for subagent events (parent_tool_use_id set):
332
+ * log tool activity so subagent work shows up in the logs, but never touch
333
+ * turn state (parts/streamingText) or channel-facing callbacks.
334
+ */
335
+ logSubagentEvent(event) {
336
+ if (event.type === "assistant" && event.message?.content) {
337
+ for (const block of event.message.content) {
338
+ if (isToolUseBlock(block)) {
339
+ if (block.id)
340
+ this.pendingToolNames.set(block.id, block.name);
341
+ log.info({ tool: block.name, subagent: true }, summarizeToolInput(block.name, block.input));
342
+ }
343
+ }
344
+ }
345
+ if (event.type === "user" && event.message?.content && Array.isArray(event.message.content)) {
346
+ this.logToolResults(event.message.content, true);
347
+ }
348
+ }
349
+ logToolResults(content, subagent) {
350
+ for (const block of content) {
351
+ if (block && typeof block === "object" && "type" in block && block.type === "tool_result") {
352
+ const tr = block;
353
+ const name = tr.tool_use_id ? this.pendingToolNames.get(tr.tool_use_id) : undefined;
354
+ if (tr.tool_use_id)
355
+ this.pendingToolNames.delete(tr.tool_use_id);
356
+ log.info({ tool: name ?? "?", is_error: tr.is_error ?? false, ...(subagent ? { subagent: true } : {}) }, `result: ${summarizeToolResult(tr.content)}`);
357
+ }
358
+ }
359
+ }
360
+ /**
361
+ * Steered-message bookkeeping from the CLI's replay events (requires the
362
+ * --replay-user-messages flag, passed via extraArgs when config.steering
363
+ * is on; non-replay user events never carry steered text verbatim, so
364
+ * matching is restricted to isReplay events to avoid false positives).
365
+ * Two shapes appear:
366
+ * - A pending steer's text echoed mid-turn → the CLI injected it into
367
+ * the in-flight turn at a tool boundary; fold it into this turn.
368
+ * - The promoted steer's own text echoed at its turn's start → the CLI
369
+ * batched the remaining queued steers into that promoted turn.
370
+ */
371
+ matchSteerEchoes(content) {
372
+ if (this.pendingSteers.length === 0)
373
+ return;
374
+ const texts = [];
375
+ if (typeof content === "string") {
376
+ texts.push(content);
377
+ }
378
+ else if (Array.isArray(content)) {
379
+ for (const block of content) {
380
+ if (isTextBlock(block))
381
+ texts.push(block.text);
382
+ }
383
+ }
384
+ for (const text of texts) {
385
+ if (this.promotedSteerText !== null && text === this.promotedSteerText) {
386
+ log.info({ session: this.sessionKey, count: this.pendingSteers.length }, "Queued steered messages batched into the promoted turn");
387
+ this.mergedRequests.push(...this.pendingSteers.map((e) => e.req));
388
+ this.pendingSteers = [];
389
+ return;
390
+ }
391
+ const idx = this.pendingSteers.findIndex((e) => e.text === text);
392
+ if (idx !== -1) {
393
+ const [entry] = this.pendingSteers.splice(idx, 1);
394
+ this.mergedRequests.push(entry.req);
395
+ log.info({ session: this.sessionKey }, "Steered message joined the in-flight turn");
396
+ }
204
397
  }
205
398
  }
206
399
  async logContextUsage(result, turnCost, totalCost, input, output, cacheRead, cacheCreated) {
@@ -241,37 +434,21 @@ export class LiveSession {
241
434
  }, "Run completed (%s)", result.subtype);
242
435
  }
243
436
  async send(text, onText, images, onBlockComplete, documents) {
437
+ if (!this.alive)
438
+ throw new Error("Session is closed");
439
+ // Steered messages can keep the session busy past the turn that the
440
+ // agent-level queue serialized against (they may run as a follow-up
441
+ // turn), so wait for true idleness before dispatching.
442
+ await this.waitForIdle();
244
443
  if (!this.alive)
245
444
  throw new Error("Session is closed");
246
445
  // Fresh maxTurns budget per user message — warnings fire once per send().
247
446
  if (this.turnBudget)
248
447
  resetTurnBudget(this.turnBudget);
249
- const TIMEOUT_MS = 10 * 60 * 1000; // 10 minute timeout per send()
250
- const content = [];
251
- if (images && images.length > 0) {
252
- for (const img of images) {
253
- content.push({
254
- type: "image",
255
- source: { type: "base64", media_type: img.mediaType, data: img.data },
256
- });
257
- }
258
- }
259
- if (documents && documents.length > 0) {
260
- for (const doc of documents) {
261
- const block = {
262
- type: "document",
263
- source: { type: "base64", media_type: doc.mediaType, data: doc.data },
264
- };
265
- if (doc.filename)
266
- block.title = doc.filename;
267
- content.push(block);
268
- }
269
- }
270
- content.push({ type: "text", text });
448
+ const content = buildContentBlocks(text, images, documents);
271
449
  return new Promise((resolve, reject) => {
272
450
  const timer = setTimeout(() => {
273
- this.currentRequest = null;
274
- reject(new Error("Query timed out after 10 minutes"));
451
+ this.failTurn(new Error("Query timed out after 10 minutes"));
275
452
  }, TIMEOUT_MS);
276
453
  const wrappedResolve = (val) => { clearTimeout(timer); resolve(val); };
277
454
  const wrappedReject = (err) => { clearTimeout(timer); reject(err); };
@@ -285,14 +462,68 @@ export class LiveSession {
285
462
  this.parts = [];
286
463
  this.streamingText = "";
287
464
  this.activeStreamBlockTypes.clear();
288
- if (this.pendingMessage && this.currentRequest) {
289
- this.pendingMessage(this.currentRequest.message);
290
- }
291
- else {
292
- wrappedReject(new Error("Session not ready to receive messages"));
293
- }
465
+ this.pushInput(this.currentRequest.message);
466
+ });
467
+ }
468
+ /**
469
+ * Inject a message into the in-flight turn instead of waiting behind it
470
+ * (config `steering`). The CLI delivers it at the next tool-call boundary;
471
+ * if the turn has no boundary left, the CLI queues it and runs it as the
472
+ * next turn. Resolution:
473
+ * - merged into the in-flight turn → resolves with STEER_MERGED; the
474
+ * turn's owner request streams and resolves the combined response.
475
+ * - ran as its own follow-up turn → resolves with that turn's response,
476
+ * streamed through the callbacks passed here.
477
+ * Falls back to a plain send() when no turn is in flight.
478
+ */
479
+ async steer(text, onText, images, onBlockComplete, documents) {
480
+ if (!this.alive)
481
+ throw new Error("Session is closed");
482
+ if (!this.isBusy())
483
+ return this.send(text, onText, images, onBlockComplete, documents);
484
+ // New instructions arrived — refresh the turn budget like any user message.
485
+ if (this.turnBudget)
486
+ resetTurnBudget(this.turnBudget);
487
+ const content = buildContentBlocks(text, images, documents);
488
+ return new Promise((resolve, reject) => {
489
+ let req = null;
490
+ const timer = setTimeout(() => {
491
+ if (req)
492
+ this.dropRequest(req, new Error("Query timed out after 10 minutes"));
493
+ }, TIMEOUT_MS);
494
+ req = {
495
+ // priority "next" is the CLI's default for queued commands; set it
496
+ // explicitly so mid-turn injection (drained at tool boundaries via
497
+ // getCommandsByMaxPriority("next")) doesn't depend on the default.
498
+ message: { type: "user", message: { role: "user", content: content }, parent_tool_use_id: null, priority: "next" },
499
+ onText,
500
+ onBlockComplete,
501
+ resolve: (val) => { clearTimeout(timer); resolve(val); },
502
+ reject: (err) => { clearTimeout(timer); reject(err); },
503
+ };
504
+ this.pendingSteers.push({ req, text });
505
+ this.pushInput(req.message);
294
506
  });
295
507
  }
508
+ /**
509
+ * Timeout path for a steered request: detach it from whichever slot it
510
+ * occupies; if it owns the in-flight turn, fail the whole turn.
511
+ */
512
+ dropRequest(req, err) {
513
+ if (this.currentRequest === req) {
514
+ this.failTurn(err);
515
+ return;
516
+ }
517
+ const mi = this.mergedRequests.indexOf(req);
518
+ if (mi !== -1)
519
+ this.mergedRequests.splice(mi, 1);
520
+ const si = this.pendingSteers.findIndex((e) => e.req === req);
521
+ if (si !== -1)
522
+ this.pendingSteers.splice(si, 1);
523
+ req.reject(err);
524
+ if (!this.isBusy())
525
+ this.notifyIdle();
526
+ }
296
527
  getSessionId() {
297
528
  return this.sessionId;
298
529
  }
@@ -301,6 +532,7 @@ export class LiveSession {
301
532
  }
302
533
  close() {
303
534
  this.alive = false;
535
+ this.inputWaiter?.();
304
536
  this.q.close();
305
537
  }
306
538
  }