polygram 0.10.0-rc.27 → 0.10.0-rc.28

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.10.0-rc.27",
4
+ "version": "0.10.0-rc.28",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -770,6 +770,12 @@ class TmuxProcess extends Process {
770
770
  // The interrupt signal still wins here too — Bug 3: an
771
771
  // interrupted tool turn writes no terminal JSONL `result`, so
772
772
  // without this racer it would hang to `turnTimeoutMs`.
773
+ //
774
+ // B10: an outstanding `Agent` subagent counts as "tool in
775
+ // flight" exactly like a foreground `Bash` — its `tool-use`
776
+ // already set `toolUsedThisTurn`, so this branch catches the
777
+ // common case. The race where capture wins BEFORE the `Agent`
778
+ // tool_use line is tailed is handled by the §6 re-check below.
773
779
  if (winner.kind === 'capture' && turn.toolUsedThisTurn) {
774
780
  winner = await Promise.race([
775
781
  turn.resultPromise.then((ev) => ({ kind: 'jsonl', ev })),
@@ -835,11 +841,41 @@ class TmuxProcess extends Process {
835
841
  text = turn.text;
836
842
  } else {
837
843
  const lateGraceMs = this.lateGraceMs ?? 1500;
838
- const late = await Promise.race([
844
+ let late = await Promise.race([
839
845
  turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
840
846
  new Promise((r) => setTimeout(() => r({ kind: 'no-jsonl' }), lateGraceMs)),
841
847
  ]);
842
- if (late.kind === 'jsonl-late') {
848
+ // B10 (shumorobot Music topic, 2026-05-20): the main agent
849
+ // delegated to an `Agent` subagent within ~7 s, then the main
850
+ // pane went quiescent for MINUTES while the subagent ran in
851
+ // its own sidechain. capture-pane read that quiescence as
852
+ // "done"; the main agent had emitted only the `Agent` call so
853
+ // no JSONL reply text existed yet, and the §6 fail-loud below
854
+ // fired ~grace-window in — closing a turn that was genuinely
855
+ // in flight. A subagent is still running iff its `Agent`
856
+ // tool_use has no matching `tool-result` yet. While one is
857
+ // outstanding, capture-pane quiescence of the MAIN pane is
858
+ // meaningless — the turn completes only when the subagent
859
+ // returns and the main agent emits its real terminal reply.
860
+ // Wait for that JSONL `result`, bounded by the absolute turn
861
+ // deadline so a genuinely wedged turn still fails loud.
862
+ if (late.kind === 'no-jsonl' && turn.outstandingSubagents.size > 0) {
863
+ this.emit('subagent-wait', {
864
+ outstanding: turn.outstandingSubagents.size,
865
+ turnId: turn.turnId,
866
+ });
867
+ late = await Promise.race([
868
+ turn.resultPromise.then((ev) => ({ kind: 'jsonl-late', ev })),
869
+ turnDeadlineP,
870
+ turn.interruptP.then(() => ({ kind: 'interrupt' })),
871
+ ]);
872
+ }
873
+ if (late.kind === 'interrupt') {
874
+ turn.interrupted = true;
875
+ text = turn.text || '';
876
+ resultSubtype = 'interrupted';
877
+ stopReason = 'interrupted';
878
+ } else if (late.kind === 'jsonl-late') {
843
879
  resolvedVia = 'jsonl-late';
844
880
  text = turn.text || late.ev.text || '';
845
881
  resultSubtype = late.ev.subtype || 'success';
@@ -968,6 +1004,13 @@ class TmuxProcess extends Process {
968
1004
  text: '',
969
1005
  toolUses: 0,
970
1006
  toolUsedThisTurn: false,
1007
+ // B10: outstanding `Agent` (subagent/Task) tool_use ids — a
1008
+ // tool_use with no matching tool_result yet. A non-empty set
1009
+ // means a subagent is running in its own sidechain context: the
1010
+ // main pane goes quiescent for MINUTES while it works, and that
1011
+ // quiescence must NOT be read as turn completion. Cleared when
1012
+ // the matching `tool-result` arrives.
1013
+ outstandingSubagents: new Set(),
971
1014
  stopReason: null,
972
1015
  resultEvent: null,
973
1016
  via: null, // autosteer: 'fold' | 'new-turn'
@@ -1145,8 +1188,25 @@ class TmuxProcess extends Process {
1145
1188
  // the flag here so a transient capture-pane "ready" between
1146
1189
  // tool calls cannot resolve a still-working turn.
1147
1190
  t.toolUsedThisTurn = true;
1191
+ // B10: an `Agent` (subagent/Task) tool_use spawns a subagent
1192
+ // that runs for MINUTES in its own sidechain context while the
1193
+ // main pane sits quiescent. Track its id as outstanding until
1194
+ // the matching `tool-result` returns — `_runTurn` treats an
1195
+ // outstanding subagent as "turn still in flight" so the main
1196
+ // pane's quiescence cannot trip the §6 fail-loud.
1197
+ if (ev.name === 'Agent' && typeof ev.id === 'string') {
1198
+ t.outstandingSubagents.add(ev.id);
1199
+ }
1148
1200
  }
1149
1201
  this.emit('tool-use', ev.name);
1202
+ } else if (ev.type === 'tool-result') {
1203
+ // B10: a subagent returned. Clear the outstanding `Agent` call
1204
+ // it answers across every turn in the active group. A
1205
+ // tool-result for a non-Agent tool (or an id we never tracked)
1206
+ // is a harmless no-op — the set only ever held `Agent` ids.
1207
+ for (const t of this._activeGroup.turns) {
1208
+ t.outstandingSubagents.delete(ev.toolUseId);
1209
+ }
1150
1210
  } else if (ev.type === 'usage') {
1151
1211
  // Token-usage snapshot from JSONL. Cache for getContextUsage().
1152
1212
  // Each assistant message carries the cumulative usage; latest
@@ -50,6 +50,7 @@
50
50
  * (ONCE per message.id, on finalize)
51
51
  * - last-prompt → 'last-prompt' (fallback complete signal)
52
52
  * - user (top-level string) → 'user-message' { text, parentUuid, promptId }
53
+ * - user tool_result block → 'tool-result' { toolUseId, isError }
53
54
  * - queue-operation → 'queue-operation' { operation, content }
54
55
  *
55
56
  * Robust against malformed lines: skips them.
@@ -128,6 +129,31 @@ function extractContentBlocks(content) {
128
129
  return { textParts, toolUses };
129
130
  }
130
131
 
132
+ /**
133
+ * Pull `tool_result` blocks out of a user message's `content` array.
134
+ * A user message with array content carries API-shaped tool feedback
135
+ * (NOT a user prompt). Each `tool_result` block names the `tool_use`
136
+ * it answers via `tool_use_id` — the matcher polygram's turn ledger
137
+ * uses to clear an outstanding `Agent`/subagent call.
138
+ *
139
+ * @returns {object[]} `tool-result` events, possibly empty.
140
+ */
141
+ function extractToolResults(content) {
142
+ const out = [];
143
+ if (!Array.isArray(content)) return out;
144
+ for (const block of content) {
145
+ if (!block || typeof block !== 'object') continue;
146
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
147
+ out.push({
148
+ type: 'tool-result',
149
+ toolUseId: block.tool_use_id,
150
+ isError: block.is_error === true,
151
+ });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
131
157
  /**
132
158
  * Join assistant text blocks the way the SDK backend's
133
159
  * `extractAssistantText` does (rc.8 cross-backend parity): blocks
@@ -204,12 +230,15 @@ function parseLine(line) {
204
230
  } else if (obj.type === 'last-prompt') {
205
231
  out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
206
232
  } else if (obj.type === 'user' && obj.message) {
207
- // Top-level user message only emit when content is a non-empty
208
- // string. Array content carries tool_result blocks (API-shaped
209
- // tool feedback), NOT a user prompt — skip those.
233
+ // Top-level user message. String content is a user prompt. Array
234
+ // content carries API-shaped `tool_result` blocks (tool feedback,
235
+ // NOT a prompt)those surface as `tool-result` events so the
236
+ // turn ledger can clear an outstanding `Agent`/subagent call.
210
237
  const content = obj.message.content;
211
238
  if (typeof content === 'string' && content.length > 0) {
212
239
  out.push({ type: 'user-message', text: content });
240
+ } else {
241
+ out.push(...extractToolResults(content));
213
242
  }
214
243
  } else if (obj.type === 'attachment' && obj.attachment) {
215
244
  const a = obj.attachment;
@@ -321,6 +350,13 @@ class SessionEventAggregator {
321
350
  parentUuid: obj.parentUuid ?? null,
322
351
  promptId: obj.promptId ?? null,
323
352
  });
353
+ } else {
354
+ // Array content — API-shaped `tool_result` blocks. A subagent
355
+ // (`Agent` tool) returning to the main agent surfaces here;
356
+ // the turn ledger keys on `toolUseId` to clear the outstanding
357
+ // subagent call so capture-pane quiescence of the main pane is
358
+ // not mistaken for turn completion while the subagent runs.
359
+ out.push(...extractToolResults(content));
324
360
  }
325
361
  } else if (obj.type === 'last-prompt') {
326
362
  out.push({ type: 'last-prompt', text: obj.lastPrompt ?? '' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.27",
3
+ "version": "0.10.0-rc.28",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {