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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/tmux-process.js +315 -0
- package/lib/process/turn-phase.js +142 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
|
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": {
|