polygram 0.10.0-rc.28 → 0.10.0-rc.29

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.28",
4
+ "version": "0.10.0-rc.29",
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",
@@ -43,6 +43,7 @@ const { getTopicConfig } = require('../session-key');
43
43
  const { POLYGRAM_DISPLAY_HINT } = require('../telegram/display-hint');
44
44
  const { verifyPinnedClaudeBin } = require('../claude-bin');
45
45
  const { createAsyncLock } = require('../async-lock');
46
+ const { TurnPhase, isLegalTransition } = require('./turn-phase');
46
47
 
47
48
  // ─── Pinned claude CLI version ───────────────────────────────────────
48
49
  //
@@ -160,6 +161,16 @@ const APPROVAL_PROMPT_RE = /Do you want to [^\n?]{1,80}\??[\s\S]{0,400}?(?:^|\n)
160
161
  // approval prompt. Capture-pane preserves the ⏺ marker.
161
162
  const TOOL_INVOCATION_RE = /⏺\s+([A-Za-z_]\w*)\s*\((.*?)\)\s*$/m;
162
163
 
164
+ // 0.10.0 predicate H1 fallback (paste-parked via capture-pane). The
165
+ // claude TUI shows this indicator when a paste was rejected to the
166
+ // queue because the previous turn was busy. The JSONL
167
+ // `queue-operation enqueue` is the primary signal for `paste-parked`
168
+ // (correlation-token bound); this regex is the fallback path when
169
+ // JSONL hasn't tailed in yet (typical 50-200ms gap between TUI
170
+ // rendering and JSONL flush) so the predicate doesn't briefly stall
171
+ // on `pasted-unconfirmed`.
172
+ const QUEUED_PASTE_RE = /Press up to edit queued messages/;
173
+
163
174
  // ─── Defaults — overridable per construction for tests ───────────────
164
175
 
165
176
  // Cold-spawn budget for claude TUI to reach "? for shortcuts" / "accept
@@ -650,6 +661,8 @@ class TmuxProcess extends Process {
650
661
  // interleave keystrokes with this primary prompt.
651
662
  const result = await this._pasteAndEnter(
652
663
  this._embedToken(turn.prompt, turn.token));
664
+ // Predicate (observer-only): paste returned, no JSONL signal yet.
665
+ this._setPhase(turn, TurnPhase.PASTED_UNCONFIRMED, 'paste:returned');
653
666
  if (result.stripped > 0) {
654
667
  this.logger.warn?.(
655
668
  `[${this.label}] stripped ${result.stripped} control chars from prompt`,
@@ -903,6 +916,16 @@ class TmuxProcess extends Process {
903
916
  const u = this._lastUsage;
904
917
  const cost = u ? computeCostUsd(u, u.model) : null;
905
918
  turn.state = 'done';
919
+ // Predicate (observer-only): terminal phase. The JSONL `result`
920
+ // event also drives this via `_evaluatePhaseFromSessionEvent`,
921
+ // but the capture-pane / interrupt / submit-fail branches do
922
+ // not; this ensures every successful exit lands the turn in
923
+ // `done` regardless of which racer won.
924
+ this._setPhase(
925
+ turn,
926
+ TurnPhase.DONE,
927
+ `runTurn:resolve:${resultSubtype}:${resolvedVia}`,
928
+ );
906
929
  turn.resolve({
907
930
  text,
908
931
  sessionId: this.claudeSessionId,
@@ -923,6 +946,15 @@ class TmuxProcess extends Process {
923
946
  });
924
947
  } catch (err) {
925
948
  turn.state = 'failed';
949
+ // Predicate (observer-only): terminal phase on the error path.
950
+ // Includes the err.code in `reason` so phase-change observers
951
+ // can distinguish TMUX_SUBMIT_FAILED / TMUX_NO_JSONL_TEXT /
952
+ // TMUX_EMPTY_JSONL_RESULT / TMUX_TURN_TIMEOUT / etc.
953
+ this._setPhase(
954
+ turn,
955
+ TurnPhase.FAILED,
956
+ `runTurn:reject:${err.code || 'tmux_send_error'}`,
957
+ );
926
958
  turn.resolve(this._errorResult(err.code || 'tmux_send_error', err.message || String(err)));
927
959
  } finally {
928
960
  this._finishTurn(turn);
@@ -995,6 +1027,7 @@ class TmuxProcess extends Process {
995
1027
  /** Build a fresh Turn record. */
996
1028
  _makeTurn({ kind, prompt = '', opts = {}, context = {}, msgIds = [] }) {
997
1029
  this._turnSeq += 1;
1030
+ const now = this._now();
998
1031
  return {
999
1032
  turnId: this._turnSeq,
1000
1033
  token: this._mintToken(),
@@ -1022,6 +1055,22 @@ class TmuxProcess extends Process {
1022
1055
  // `_runTurn` race end promptly instead of hanging until
1023
1056
  // `turnTimeoutMs`. Armed at the top of `_runTurn`.
1024
1057
  signalInterrupt: null, interruptP: null, interrupted: false,
1058
+ // 0.10.0 predicate (Commit 1, observer-only): unified turn
1059
+ // phase. See lib/process/turn-phase.js. Mutated only via
1060
+ // `_setPhase`; consumed by no existing control flow in this
1061
+ // commit. Subsequent commits replace the patience timers that
1062
+ // currently race on a turn's behalf (`_runTurn`'s 5-way race,
1063
+ // §6 fail-loud, B10 outstanding-Agent suppression).
1064
+ phase: TurnPhase.QUEUED,
1065
+ phaseSince: now,
1066
+ lastActivityAt: now,
1067
+ submitConfirmed: false,
1068
+ parked: false,
1069
+ // Generalisation of `outstandingSubagents` — every non-Agent
1070
+ // tool_use's id while it is in flight. Today only Agent is
1071
+ // tracked (B10); the predicate uses both sets to keep `quiet`
1072
+ // unreachable while any tool is genuinely outstanding.
1073
+ outstandingTools: new Set(),
1025
1074
  };
1026
1075
  }
1027
1076
 
@@ -1086,6 +1135,175 @@ class TmuxProcess extends Process {
1086
1135
  this._pruneLedger();
1087
1136
  }
1088
1137
 
1138
+ // ─── 0.10.0 turn-phase predicate (Commit 1: observer-only) ────────
1139
+ //
1140
+ // The five-agent investigation (docs/0.10.0-tmux-patience-model-
1141
+ // solution.md) found 14+ uncoordinated patience timers, several
1142
+ // mutually inconsistent under the music-curation workload. The fix
1143
+ // is one state machine fed by all signals continuously; timers only
1144
+ // demote phases on silence, never advance them.
1145
+ //
1146
+ // This commit lands the predicate as an OBSERVER. Every signal that
1147
+ // reaches `_handleSessionEvent`, the capture-pane poll, and the
1148
+ // debug-log poll feeds `_setPhase` / `_heartbeat`. `phase-change`
1149
+ // events fire for observability. **No existing control flow reads
1150
+ // `turn.phase` yet** — `_runTurn`'s 5-way race, §6 fail-loud, B10
1151
+ // outstanding-Agent suppression, and `_sweepStaleTurns` remain
1152
+ // exactly as they are. Commits 2-3 replace them one at a time.
1153
+
1154
+ /**
1155
+ * Transition `turn` to `next` phase if and only if it is different
1156
+ * from the current phase. Emits a `phase-change` event for
1157
+ * observability. Soft-asserts the transition is legal — logs at
1158
+ * warn level and proceeds (observer-only must never block).
1159
+ *
1160
+ * @param {object} turn - the turn whose phase changes
1161
+ * @param {string} next - the next TurnPhase value
1162
+ * @param {string} reason - short tag for the trigger (e.g. 'jsonl:user-message')
1163
+ * @returns {boolean} true iff phase actually changed
1164
+ */
1165
+ _setPhase(turn, next, reason) {
1166
+ if (!turn) return false;
1167
+ const prev = turn.phase;
1168
+ if (prev === next) return false;
1169
+ if (!isLegalTransition(prev, next)) {
1170
+ // Observer-only: log and proceed. A real illegal transition is
1171
+ // either a bug in the wiring or a signal pattern the design
1172
+ // didn't anticipate — both worth surfacing without breaking the
1173
+ // turn. Tests assert the legal graph; production prefers honesty
1174
+ // over rigidity.
1175
+ this.logger.warn?.(
1176
+ `[${this.label}] phase illegal transition: ${prev} → ${next}`
1177
+ + ` (turn ${turn.turnId}, reason ${reason})`,
1178
+ );
1179
+ }
1180
+ const now = this._now();
1181
+ turn.phase = next;
1182
+ turn.phaseSince = now;
1183
+ turn.lastActivityAt = now;
1184
+ this.emit('phase-change', {
1185
+ turnId: turn.turnId,
1186
+ msgId: turn.msgIds[0] ?? null,
1187
+ kind: turn.kind,
1188
+ prev,
1189
+ next,
1190
+ reason,
1191
+ ts: now,
1192
+ sessionId: this.claudeSessionId,
1193
+ backend: 'tmux',
1194
+ });
1195
+ return true;
1196
+ }
1197
+
1198
+ /**
1199
+ * Mark a turn as having received a liveness signal at this instant.
1200
+ * Bumps `lastActivityAt`; does NOT change phase by itself. The
1201
+ * demote-on-silence path (future commit) compares `lastActivityAt`
1202
+ * to `quietToleranceMs` to decide when to demote to `quiet`.
1203
+ *
1204
+ * Sources that should call this on every event:
1205
+ * - assistant-chunk / tool-use / tool-result / usage (JSONL)
1206
+ * - capture-pane sees `esc to interrupt`
1207
+ * - debug-log size grew
1208
+ *
1209
+ * Phase-advancing signals call `_setPhase` instead (which also
1210
+ * bumps `lastActivityAt` internally).
1211
+ */
1212
+ _heartbeat(turn, source) {
1213
+ if (!turn) return;
1214
+ turn.lastActivityAt = this._now();
1215
+ // No event emission — heartbeats are high-frequency; the
1216
+ // phase-change event is the consumer-visible surface. Source tag
1217
+ // is reserved for future debug telemetry.
1218
+ void source;
1219
+ }
1220
+
1221
+ /**
1222
+ * Drive the predicate for an active group from a JSONL session
1223
+ * event. Pure routing — each event maps to a phase transition (or
1224
+ * a heartbeat for in-phase signals).
1225
+ *
1226
+ * Called from `_handleSessionEvent` AFTER the existing branches do
1227
+ * their work, so today's behaviour is untouched.
1228
+ *
1229
+ * @param {object} ev - the parsed session-log event
1230
+ * @param {Array<object>} turns - the active group turns (may be empty)
1231
+ */
1232
+ _evaluatePhaseFromSessionEvent(ev, turns) {
1233
+ if (!Array.isArray(turns) || turns.length === 0) return;
1234
+ for (const t of turns) {
1235
+ // Don't move terminal phases — `done`/`failed` are absorbing.
1236
+ if (t.phase === TurnPhase.DONE || t.phase === TurnPhase.FAILED) continue;
1237
+
1238
+ if (ev.type === 'user-message') {
1239
+ // Submit landed. Set both the flag and the phase.
1240
+ if (!t.submitConfirmed) t.submitConfirmed = true;
1241
+ this._setPhase(t, TurnPhase.SUBMITTED, 'jsonl:user-message');
1242
+ } else if (ev.type === 'assistant-chunk') {
1243
+ this._setPhase(t, TurnPhase.STREAMING, 'jsonl:assistant-chunk');
1244
+ } else if (ev.type === 'tool-use') {
1245
+ if (typeof ev.id === 'string' && ev.name !== 'Agent') {
1246
+ t.outstandingTools.add(ev.id);
1247
+ }
1248
+ // Phase choice: subagent > tool > streaming. A Bash tool-use
1249
+ // arriving while an Agent is outstanding must NOT demote
1250
+ // SUBAGENT_RUNNING — both are "in flight," but the predicate's
1251
+ // public contract is "the most-restrictive active phase wins."
1252
+ // Agent absence + outstanding Agent set is impossible (the
1253
+ // existing branch added before us), so we read both sets.
1254
+ if (t.outstandingSubagents.size > 0) {
1255
+ this._setPhase(t, TurnPhase.SUBAGENT_RUNNING, `jsonl:tool-use:${ev.name || 'unknown'}`);
1256
+ } else if (t.outstandingTools.size > 0) {
1257
+ this._setPhase(t, TurnPhase.TOOL_RUNNING, `jsonl:tool-use:${ev.name || 'unknown'}`);
1258
+ } else {
1259
+ // No id (rare — older event shapes) — heartbeat only.
1260
+ this._heartbeat(t, 'jsonl:tool-use:no-id');
1261
+ }
1262
+ } else if (ev.type === 'tool-result') {
1263
+ if (typeof ev.toolUseId === 'string') {
1264
+ t.outstandingTools.delete(ev.toolUseId);
1265
+ }
1266
+ // Phase choice: prefer the most-active outstanding state.
1267
+ // Subagent > tool > streaming.
1268
+ if (t.outstandingSubagents.size > 0) {
1269
+ // Still waiting on a subagent; phase stays.
1270
+ this._heartbeat(t, 'jsonl:tool-result:subagent-still-out');
1271
+ } else if (t.outstandingTools.size > 0) {
1272
+ this._setPhase(t, TurnPhase.TOOL_RUNNING, 'jsonl:tool-result:tool-still-out');
1273
+ } else {
1274
+ this._setPhase(t, TurnPhase.STREAMING, 'jsonl:tool-result:drained');
1275
+ }
1276
+ } else if (ev.type === 'usage') {
1277
+ this._heartbeat(t, 'jsonl:usage');
1278
+ } else if (ev.type === 'result') {
1279
+ if (ev.subtype === 'tool_use') {
1280
+ // Non-terminal — agent paused for a tool. The matching
1281
+ // tool-use line will have moved the phase already.
1282
+ this._heartbeat(t, 'jsonl:result:tool_use-nonterm');
1283
+ } else {
1284
+ // Terminal stop_reason — turn ended.
1285
+ this._setPhase(t, TurnPhase.DONE, `jsonl:result:${ev.subtype || 'success'}`);
1286
+ }
1287
+ } else if (ev.type === 'queue-operation') {
1288
+ if (ev.operation === 'enqueue' && ev.content) {
1289
+ const tokens = this._extractTokens(ev.content);
1290
+ if (tokens.includes(t.token)) {
1291
+ // Our paste landed in the TUI queue (proof of arrival AND
1292
+ // proof of parking). The W11/W25 C1 resolution: the
1293
+ // future B7 successor will read `turn.parked === true`
1294
+ // and stop re-sending Enter.
1295
+ t.parked = true;
1296
+ this._setPhase(t, TurnPhase.PASTE_PARKED, 'jsonl:queue-operation:enqueue');
1297
+ }
1298
+ }
1299
+ // remove/dequeue do not change phase by themselves — the
1300
+ // follow-up user-message moves the turn to `submitted` if it
1301
+ // is the head being released.
1302
+ }
1303
+ // Other event types (last-prompt, queue-folded) — observer-only
1304
+ // no-ops in this commit.
1305
+ }
1306
+ }
1089
1307
  _dropFromActiveGroup(turn) {
1090
1308
  this._activeGroup.turns = this._activeGroup.turns.filter((t) => t !== turn);
1091
1309
  }
@@ -1147,6 +1365,16 @@ class TmuxProcess extends Process {
1147
1365
  }
1148
1366
 
1149
1367
  _handleSessionEvent(ev) {
1368
+ // Predicate (observer-only): snapshot the active group's turns
1369
+ // BEFORE the existing branches run. The `result` and `last-prompt`
1370
+ // branches flush the group (clearing `_activeGroup.turns`) — if
1371
+ // the predicate evaluates against `this._activeGroup.turns` at the
1372
+ // bottom of this method, it sees an empty array for those events.
1373
+ // The snapshot is by-reference (same turn objects), so any
1374
+ // mutations the existing branches make (e.g. outstandingSubagents
1375
+ // updates) ARE visible to the predicate.
1376
+ const activeTurnsSnapshot = (this._activeGroup?.turns || []).slice();
1377
+
1150
1378
  if (ev.type === 'assistant-chunk') {
1151
1379
  // Assistant text belongs to whatever turn(s) the latest
1152
1380
  // `user-message` token routed into the active group. The
@@ -1319,6 +1547,59 @@ class TmuxProcess extends Process {
1319
1547
  });
1320
1548
  }
1321
1549
  }
1550
+
1551
+ // 0.10.0 predicate (Commit 1, observer-only): every event also
1552
+ // drives the phase machine. Runs LAST so today's existing
1553
+ // branches above have already executed and the predicate sees
1554
+ // post-mutation state (e.g. `outstandingSubagents` already
1555
+ // updated for `tool-use`/`tool-result`). Emits `phase-change`
1556
+ // events only — no consumer reads `turn.phase` in this commit.
1557
+ //
1558
+ // `queue-operation enqueue` and `user-message` may route to
1559
+ // turns OUTSIDE the active group (an autosteer in the ledger
1560
+ // that has not yet folded). For those we evaluate the ledger
1561
+ // turn directly.
1562
+ try {
1563
+ this._evaluatePhaseFromSessionEvent(ev, activeTurnsSnapshot);
1564
+ if (ev.type === 'queue-operation'
1565
+ && ev.operation === 'enqueue'
1566
+ && ev.content) {
1567
+ const tokens = this._extractTokens(ev.content);
1568
+ const extra = [];
1569
+ for (const tok of tokens) {
1570
+ const t = this._ledger.find(
1571
+ (x) => x.token === tok
1572
+ && x.state !== 'done' && x.state !== 'failed'
1573
+ && !activeTurnsSnapshot.includes(x),
1574
+ );
1575
+ if (t) extra.push(t);
1576
+ }
1577
+ if (extra.length > 0) {
1578
+ this._evaluatePhaseFromSessionEvent(ev, extra);
1579
+ }
1580
+ }
1581
+ if (ev.type === 'user-message') {
1582
+ const tokens = this._extractTokens(ev.text);
1583
+ const extra = [];
1584
+ for (const tok of tokens) {
1585
+ const t = this._ledger.find(
1586
+ (x) => x.token === tok
1587
+ && x.state !== 'done' && x.state !== 'failed'
1588
+ && !activeTurnsSnapshot.includes(x),
1589
+ );
1590
+ if (t) extra.push(t);
1591
+ }
1592
+ if (extra.length > 0) {
1593
+ this._evaluatePhaseFromSessionEvent(ev, extra);
1594
+ }
1595
+ }
1596
+ } catch (err) {
1597
+ // Observer-only: a bug in the predicate must not break the
1598
+ // existing turn flow. Log and swallow.
1599
+ this.logger.warn?.(
1600
+ `[${this.label}] predicate error: ${err.message || err}`,
1601
+ );
1602
+ }
1322
1603
  }
1323
1604
 
1324
1605
  /**
@@ -1862,10 +2143,34 @@ class TmuxProcess extends Process {
1862
2143
  const bottomTail = lastBuf.slice(-2000); // ~10-20 lines of pane bottom
1863
2144
  cachedReady = READY_HINTS_RE.test(lastBuf) && !TUI_BANNER_RE.test(bottomTail);
1864
2145
  cachedStreaming = STREAMING_HINT_RE.test(lastBuf);
2146
+ // Predicate (observer-only): capture-pane signals heartbeat
2147
+ // the active turns. `esc to interrupt` is the single most-
2148
+ // useful TUI signal (Agent C) — always present mid-turn,
2149
+ // including during subagent runs. The H1 indicator is the
2150
+ // fallback for `paste-parked` when JSONL hasn't tailed yet.
2151
+ const activeTurns = this._activeGroup?.turns || [];
2152
+ if (cachedStreaming) {
2153
+ for (const t of activeTurns) this._heartbeat(t, 'capture:streaming');
2154
+ }
2155
+ if (QUEUED_PASTE_RE.test(lastBuf)) {
2156
+ for (const t of activeTurns) {
2157
+ if (t.phase === TurnPhase.PASTED_UNCONFIRMED && !t.parked) {
2158
+ t.parked = true;
2159
+ this._setPhase(t, TurnPhase.PASTE_PARKED, 'capture:queued-fallback');
2160
+ }
2161
+ }
2162
+ }
1865
2163
  // Approval-prompt detection ONLY runs on changed captures.
1866
2164
  // It's the heaviest regex (`[\s\S]{0,400}?` non-greedy) so
1867
2165
  // worth skipping on quiescent ticks.
1868
2166
  if (APPROVAL_PROMPT_RE.test(lastBuf)) {
2167
+ // Predicate (observer-only): approval-pending blocks turn
2168
+ // progress until respondToApproval lands. Mark BEFORE the
2169
+ // existing handler runs so observers see the transition
2170
+ // before any side-effects.
2171
+ for (const t of activeTurns) {
2172
+ this._setPhase(t, TurnPhase.APPROVAL_PENDING, 'capture:approval-prompt');
2173
+ }
1869
2174
  await this._handleApprovalPrompt(lastBuf);
1870
2175
  firstReadyAt = null; // approval pause resets ready clock
1871
2176
  if (await raceAbort(this._waitForNextTick()) === ABORT_SENTINEL) {
@@ -1959,6 +2264,16 @@ class TmuxProcess extends Process {
1959
2264
  await this.runner.sendControl(this.tmuxName, 'Enter');
1960
2265
  }
1961
2266
  this._pendingApprovalId = null;
2267
+ // Predicate (observer-only): the approval was decided. The
2268
+ // assistant will resume — demote `approval-pending` back to
2269
+ // `streaming` on every active-group turn that is still in
2270
+ // that phase. The next assistant-chunk / tool-use will move
2271
+ // them again from there.
2272
+ for (const t of this._activeGroup?.turns || []) {
2273
+ if (t.phase === TurnPhase.APPROVAL_PENDING) {
2274
+ this._setPhase(t, TurnPhase.STREAMING, `approval:${decision}`);
2275
+ }
2276
+ }
1962
2277
  return true;
1963
2278
  } catch (err) {
1964
2279
  this.emit('approval-fail', { id, err: err.message });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * TurnPhase — unified per-turn liveness state.
3
+ *
4
+ * Companion to `docs/0.10.0-tmux-patience-model-solution.md`. This
5
+ * module exports the enum and the active/inactive partition only.
6
+ * Phase computation lives on TmuxProcess so it can reach turn state
7
+ * (`outstandingTools`, `outstandingSubagents`, `submitConfirmed`,
8
+ * `parked`) without leaking those internals here.
9
+ *
10
+ * Commit 1 (predicate as observer):
11
+ * - Every signal that reaches `_handleSessionEvent` / the capture
12
+ * poll / the debug-log poll feeds the predicate.
13
+ * - The predicate transitions per-turn state and emits a
14
+ * `phase-change` event.
15
+ * - **NO existing control flow consumes `turn.phase` yet** — the
16
+ * 5-way `Promise.race` in `_runTurn`, the §6 fail-loud branch,
17
+ * B10's outstanding-Agent check, `_sweepStaleTurns`, and the
18
+ * reactor remain untouched. This commit is purely additive.
19
+ *
20
+ * Subsequent commits replace those consumers one at a time
21
+ * (Commit 2: `_confirmSubmitViaJsonl`; Commit 3: `_runTurn`'s race).
22
+ *
23
+ * @see docs/0.10.0-tmux-patience-model-solution.md
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ /**
29
+ * The 13-state phase enum.
30
+ *
31
+ * queued in pendingQueue, not yet pasted
32
+ * pasted-unconfirmed _pasteAndEnter returned; no JSONL signal yet
33
+ * paste-parked queue-operation enqueue carrying our corr-id seen,
34
+ * or capture-pane fallback ("Press up to edit
35
+ * queued messages")
36
+ * submitted JSONL user-message reproducing our corr-id seen
37
+ * streaming assistant text/thinking arriving
38
+ * tool-running ≥1 outstanding non-Agent tool_use
39
+ * subagent-running ≥1 outstanding `Agent` tool_use (existing B10)
40
+ * bg-shell-running session-level (no in-flight turn); TUI shows
41
+ * `N shell` — not per-turn but included for
42
+ * catalogue completeness
43
+ * approval-pending approval prompt detected; awaiting respondToApproval
44
+ * quiet no active phase + no heartbeat for quietToleranceMs
45
+ * wedged `quiet` held for quietToWedgedMs with empty
46
+ * outstanding sets AND submitConfirmed
47
+ * done terminal JSONL `result` flushed
48
+ * failed explicit failure (TMUX_SUBMIT_FAILED, kill, drain)
49
+ */
50
+ const TurnPhase = Object.freeze({
51
+ QUEUED: 'queued',
52
+ PASTED_UNCONFIRMED: 'pasted-unconfirmed',
53
+ PASTE_PARKED: 'paste-parked',
54
+ SUBMITTED: 'submitted',
55
+ STREAMING: 'streaming',
56
+ TOOL_RUNNING: 'tool-running',
57
+ SUBAGENT_RUNNING: 'subagent-running',
58
+ BG_SHELL_RUNNING: 'bg-shell-running',
59
+ APPROVAL_PENDING: 'approval-pending',
60
+ QUIET: 'quiet',
61
+ WEDGED: 'wedged',
62
+ DONE: 'done',
63
+ FAILED: 'failed',
64
+ });
65
+
66
+ /**
67
+ * Active phases — the predicate's "turn is making progress" set.
68
+ *
69
+ * Any phase here means we have *positive evidence* the turn is alive:
70
+ * either we just acted on it (paste/park/submit), or a signal
71
+ * arrived (streaming/tool/subagent/approval), or it's holding the
72
+ * session (bg-shell).
73
+ *
74
+ * Inactive: queued (not started), quiet (silence — demoted), wedged
75
+ * (silence past the wedged threshold), done/failed (terminal).
76
+ */
77
+ const ACTIVE_PHASES = Object.freeze(new Set([
78
+ TurnPhase.PASTED_UNCONFIRMED,
79
+ TurnPhase.PASTE_PARKED,
80
+ TurnPhase.SUBMITTED,
81
+ TurnPhase.STREAMING,
82
+ TurnPhase.TOOL_RUNNING,
83
+ TurnPhase.SUBAGENT_RUNNING,
84
+ TurnPhase.BG_SHELL_RUNNING,
85
+ TurnPhase.APPROVAL_PENDING,
86
+ ]));
87
+
88
+ const TERMINAL_PHASES = Object.freeze(new Set([
89
+ TurnPhase.DONE,
90
+ TurnPhase.FAILED,
91
+ ]));
92
+
93
+ /** True when phase is one of the "turn making progress" states. */
94
+ function isActive(phase) {
95
+ return ACTIVE_PHASES.has(phase);
96
+ }
97
+
98
+ /** True when phase is terminal — `done` or `failed`. */
99
+ function isTerminal(phase) {
100
+ return TERMINAL_PHASES.has(phase);
101
+ }
102
+
103
+ /**
104
+ * The set of phases that are reachable FROM a given phase. Used by
105
+ * tests to assert no illegal jumps; also documents the state machine
106
+ * shape in code. Terminal phases have no successors.
107
+ *
108
+ * The graph is intentionally permissive — observer-only Commit 1
109
+ * doesn't gate any transitions; future commits MAY tighten this.
110
+ */
111
+ const ALLOWED_TRANSITIONS = Object.freeze({
112
+ [TurnPhase.QUEUED]: new Set([TurnPhase.PASTED_UNCONFIRMED, TurnPhase.FAILED]),
113
+ [TurnPhase.PASTED_UNCONFIRMED]: new Set([TurnPhase.PASTE_PARKED, TurnPhase.SUBMITTED, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
114
+ [TurnPhase.PASTE_PARKED]: new Set([TurnPhase.SUBMITTED, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
115
+ [TurnPhase.SUBMITTED]: new Set([TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
116
+ [TurnPhase.STREAMING]: new Set([TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
117
+ [TurnPhase.TOOL_RUNNING]: new Set([TurnPhase.TOOL_RUNNING, TurnPhase.STREAMING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
118
+ [TurnPhase.SUBAGENT_RUNNING]: new Set([TurnPhase.SUBAGENT_RUNNING, TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
119
+ [TurnPhase.BG_SHELL_RUNNING]: new Set([TurnPhase.STREAMING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
120
+ [TurnPhase.APPROVAL_PENDING]: new Set([TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.QUIET, TurnPhase.DONE, TurnPhase.FAILED]),
121
+ [TurnPhase.QUIET]: new Set([TurnPhase.STREAMING, TurnPhase.TOOL_RUNNING, TurnPhase.SUBAGENT_RUNNING, TurnPhase.APPROVAL_PENDING, TurnPhase.WEDGED, TurnPhase.DONE, TurnPhase.FAILED]),
122
+ [TurnPhase.WEDGED]: new Set([TurnPhase.STREAMING, TurnPhase.DONE, TurnPhase.FAILED]),
123
+ [TurnPhase.DONE]: new Set(),
124
+ [TurnPhase.FAILED]: new Set(),
125
+ });
126
+
127
+ /** True iff `next` is a legal successor of `prev`. */
128
+ function isLegalTransition(prev, next) {
129
+ if (prev === next) return true;
130
+ const successors = ALLOWED_TRANSITIONS[prev];
131
+ return Boolean(successors && successors.has(next));
132
+ }
133
+
134
+ module.exports = {
135
+ TurnPhase,
136
+ ACTIVE_PHASES,
137
+ TERMINAL_PHASES,
138
+ ALLOWED_TRANSITIONS,
139
+ isActive,
140
+ isTerminal,
141
+ isLegalTransition,
142
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.10.0-rc.28",
3
+ "version": "0.10.0-rc.29",
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": {