polygram 0.37.2 → 0.38.1

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.
@@ -0,0 +1,260 @@
1
+ 'use strict';
2
+
3
+ const { createHash } = require('node:crypto');
4
+
5
+ const SHA256_RE = /^[a-f0-9]{64}$/;
6
+ const PROCESS_STATES = new Set([
7
+ 'Spawning', 'Initializing', 'AttachingThread', 'Idle', 'StartingTurn',
8
+ 'Active', 'BackgroundWorking', 'BackgroundSettling', 'Settling',
9
+ 'Quiescing', 'Stopped', 'Closing', 'Closed', 'FailedAmbiguous',
10
+ 'RecoveryConflict', 'DurabilityBlocked', 'ContainmentFailed', 'unknown',
11
+ ]);
12
+ const OUTCOME_CODES = new Set([
13
+ 'eligible', 'active-turn', 'pending-delivery', 'background-owner',
14
+ 'background-terminals', 'pagination-incomplete', 'process-not-idle',
15
+ 'process-not-ready', 'no-codex-generation', 'unsupported', 'lease-drift',
16
+ 'generation-drift', 'activity-epoch-drift', 'fence-drift',
17
+ 'inspection-failed', 'invalid-expectation', 'incoherent',
18
+ ]);
19
+ const OBSERVATION_KEYS = [
20
+ 'outcome',
21
+ 'reason',
22
+ 'generationDigest',
23
+ 'activityEpoch',
24
+ 'processState',
25
+ 'activeTurnCount',
26
+ 'pendingDeliveryCount',
27
+ 'backgroundOwnerCount',
28
+ 'backgroundTerminalCount',
29
+ 'backgroundTerminalRegistryComplete',
30
+ 'observedAtMs',
31
+ ];
32
+
33
+ function isPlainObject(value) {
34
+ return value != null
35
+ && typeof value === 'object'
36
+ && !Array.isArray(value)
37
+ && Object.getPrototypeOf(value) === Object.prototype;
38
+ }
39
+
40
+ function hasExactKeys(value, expectedKeys) {
41
+ if (!isPlainObject(value)) return false;
42
+ const keys = Object.keys(value);
43
+ return keys.length === expectedKeys.length
44
+ && expectedKeys.every((key) => Object.hasOwn(value, key));
45
+ }
46
+
47
+ function validOptionalFence(value, validator) {
48
+ return value === null || validator(value);
49
+ }
50
+
51
+ function fallbackObservation(now = Date.now) {
52
+ return Object.freeze({
53
+ outcome: 'mismatch',
54
+ reason: 'inspection-failed',
55
+ generationDigest: null,
56
+ activityEpoch: null,
57
+ processState: 'unknown',
58
+ activeTurnCount: 0,
59
+ pendingDeliveryCount: 0,
60
+ backgroundOwnerCount: 0,
61
+ backgroundTerminalCount: 0,
62
+ backgroundTerminalRegistryComplete: false,
63
+ observedAtMs: now(),
64
+ });
65
+ }
66
+
67
+ function normalizeQualificationObservation(observation, { now = Date.now } = {}) {
68
+ if (
69
+ !hasExactKeys(observation, OBSERVATION_KEYS)
70
+ || !['qualified', 'mismatch'].includes(observation.outcome)
71
+ || !OUTCOME_CODES.has(observation.reason)
72
+ || !validOptionalFence(
73
+ observation.generationDigest,
74
+ (value) => SHA256_RE.test(value),
75
+ )
76
+ || !validOptionalFence(
77
+ observation.activityEpoch,
78
+ (value) => Number.isSafeInteger(value) && value >= 0,
79
+ )
80
+ || !PROCESS_STATES.has(observation.processState)
81
+ || ![0, 1].includes(observation.activeTurnCount)
82
+ || ![0, 1].includes(observation.pendingDeliveryCount)
83
+ || ![0, 1].includes(observation.backgroundOwnerCount)
84
+ || !Number.isSafeInteger(observation.backgroundTerminalCount)
85
+ || observation.backgroundTerminalCount < 0
86
+ || observation.backgroundTerminalCount > 1_600
87
+ || typeof observation.backgroundTerminalRegistryComplete !== 'boolean'
88
+ || !Number.isSafeInteger(observation.observedAtMs)
89
+ || observation.observedAtMs < 0
90
+ ) {
91
+ return fallbackObservation(now);
92
+ }
93
+
94
+ const qualified = observation.outcome === 'qualified';
95
+ const eligibleShape = (
96
+ observation.reason === 'eligible'
97
+ && SHA256_RE.test(observation.generationDigest)
98
+ && Number.isSafeInteger(observation.activityEpoch)
99
+ && observation.processState === 'Idle'
100
+ && observation.activeTurnCount === 0
101
+ && observation.pendingDeliveryCount === 0
102
+ && observation.backgroundOwnerCount === 0
103
+ && observation.backgroundTerminalCount === 0
104
+ && observation.backgroundTerminalRegistryComplete === true
105
+ );
106
+ if (
107
+ (qualified && !eligibleShape)
108
+ || (!qualified && observation.reason === 'eligible')
109
+ ) {
110
+ return fallbackObservation(now);
111
+ }
112
+
113
+ return Object.freeze({
114
+ outcome: observation.outcome,
115
+ reason: observation.reason,
116
+ generationDigest: observation.generationDigest,
117
+ activityEpoch: observation.activityEpoch,
118
+ processState: observation.processState,
119
+ activeTurnCount: observation.activeTurnCount,
120
+ pendingDeliveryCount: observation.pendingDeliveryCount,
121
+ backgroundOwnerCount: observation.backgroundOwnerCount,
122
+ backgroundTerminalCount: observation.backgroundTerminalCount,
123
+ backgroundTerminalRegistryComplete: observation.backgroundTerminalRegistryComplete,
124
+ observedAtMs: observation.observedAtMs,
125
+ });
126
+ }
127
+
128
+ function normalizeDeployQualificationExpectation(request) {
129
+ if (!isPlainObject(request)) return null;
130
+ const requestKeys = Object.keys(request);
131
+ const allowedRequestKeys = new Set([
132
+ 'op', 'id', 'secret', 'qualification_expectation',
133
+ ]);
134
+ const hasUnknownRequestField = requestKeys.some(
135
+ (key) => !allowedRequestKeys.has(key),
136
+ );
137
+ const hasExpectation = Object.hasOwn(request, 'qualification_expectation');
138
+ if (!hasExpectation && !hasUnknownRequestField) return undefined;
139
+ if (hasUnknownRequestField) return null;
140
+
141
+ const expectation = request.qualification_expectation;
142
+ if (!hasExactKeys(expectation, ['generation_digest', 'activity_epoch'])) {
143
+ return null;
144
+ }
145
+ if (
146
+ !SHA256_RE.test(expectation.generation_digest)
147
+ || !Number.isSafeInteger(expectation.activity_epoch)
148
+ || expectation.activity_epoch < 0
149
+ ) {
150
+ return null;
151
+ }
152
+ return Object.freeze({
153
+ expectedGenerationDigest: expectation.generation_digest,
154
+ expectedActivityEpoch: expectation.activity_epoch,
155
+ });
156
+ }
157
+
158
+ function projectQualificationResponse({
159
+ botName,
160
+ daemonIdentity,
161
+ observation,
162
+ now = Date.now,
163
+ } = {}) {
164
+ const normalized = normalizeQualificationObservation(observation, { now });
165
+ return Object.freeze({
166
+ bot: botName,
167
+ daemon_instance_id: daemonIdentity?.daemon_instance_id ?? null,
168
+ package_version: daemonIdentity?.package_version ?? null,
169
+ observed_at_ms: normalized.observedAtMs,
170
+ generation_digest: normalized.generationDigest,
171
+ activity_epoch: normalized.activityEpoch,
172
+ process_state: normalized.processState,
173
+ active_turn_count: normalized.activeTurnCount,
174
+ pending_delivery_count: normalized.pendingDeliveryCount,
175
+ background_owner_count: normalized.backgroundOwnerCount,
176
+ background_terminal_count: normalized.backgroundTerminalCount,
177
+ background_terminal_registry_complete: normalized.backgroundTerminalRegistryComplete,
178
+ outcome_code: normalized.reason,
179
+ });
180
+ }
181
+
182
+ function buildQualificationEvent({
183
+ botName,
184
+ daemonIdentity,
185
+ restartRequestId,
186
+ expectation,
187
+ observation,
188
+ now = Date.now,
189
+ } = {}) {
190
+ const normalized = normalizeQualificationObservation(observation, { now });
191
+ const expectationValid = (
192
+ isPlainObject(expectation)
193
+ && hasExactKeys(expectation, [
194
+ 'expectedGenerationDigest',
195
+ 'expectedActivityEpoch',
196
+ ])
197
+ && SHA256_RE.test(expectation.expectedGenerationDigest)
198
+ && Number.isSafeInteger(expectation.expectedActivityEpoch)
199
+ && expectation.expectedActivityEpoch >= 0
200
+ );
201
+ const exactMatch = Boolean(
202
+ expectationValid
203
+ && normalized.outcome === 'qualified'
204
+ && normalized.reason === 'eligible'
205
+ && normalized.generationDigest === expectation.expectedGenerationDigest
206
+ && normalized.activityEpoch === expectation.expectedActivityEpoch
207
+ && normalized.processState === 'Idle'
208
+ && normalized.activeTurnCount === 0
209
+ && normalized.pendingDeliveryCount === 0
210
+ && normalized.backgroundOwnerCount === 0
211
+ && normalized.backgroundTerminalCount === 0
212
+ && normalized.backgroundTerminalRegistryComplete === true
213
+ );
214
+ return Object.freeze({
215
+ bot: botName,
216
+ restart_request_sha256: createHash('sha256')
217
+ .update(restartRequestId)
218
+ .digest('hex'),
219
+ daemon_instance_id: daemonIdentity?.daemon_instance_id ?? null,
220
+ package_version: daemonIdentity?.package_version ?? null,
221
+ observed_at_ms: normalized.observedAtMs,
222
+ generation_digest: normalized.generationDigest,
223
+ expected_activity_epoch: expectationValid
224
+ ? expectation.expectedActivityEpoch
225
+ : null,
226
+ observed_activity_epoch: normalized.activityEpoch,
227
+ process_state: normalized.processState,
228
+ active_turn_count: normalized.activeTurnCount,
229
+ pending_delivery_count: normalized.pendingDeliveryCount,
230
+ background_owner_count: normalized.backgroundOwnerCount,
231
+ background_terminal_count: normalized.backgroundTerminalCount,
232
+ background_terminal_registry_complete: normalized.backgroundTerminalRegistryComplete,
233
+ exact_match: exactMatch,
234
+ outcome_code: normalized.reason,
235
+ });
236
+ }
237
+
238
+ function assertClosedQualificationRequest(request) {
239
+ if (!isPlainObject(request)) {
240
+ throw new TypeError('clean restart qualification request is invalid');
241
+ }
242
+ const allowed = new Set(['op', 'id', 'secret']);
243
+ if (
244
+ request.op !== 'clean_restart_qualification'
245
+ || Object.keys(request).some((key) => !allowed.has(key))
246
+ ) {
247
+ const error = new Error('clean restart qualification request has unknown fields');
248
+ error.code = 'INVALID_CLEAN_RESTART_QUALIFICATION_REQUEST';
249
+ throw error;
250
+ }
251
+ return request;
252
+ }
253
+
254
+ module.exports = {
255
+ assertClosedQualificationRequest,
256
+ buildQualificationEvent,
257
+ normalizeDeployQualificationExpectation,
258
+ normalizeQualificationObservation,
259
+ projectQualificationResponse,
260
+ };
@@ -147,6 +147,17 @@ async function executeCleanResumeClaim(claim, {
147
147
  policy_version: claim?.policy_version ?? null,
148
148
  };
149
149
  const codex = isCodexClaim(claim);
150
+ let continuationDispatched = false;
151
+ const markContinuationDispatched = () => {
152
+ if (continuationDispatched) return;
153
+ continuationDispatched = true;
154
+ logEvent('clean-resume-continuation-dispatched', {
155
+ ...eventDetail,
156
+ provider: codex ? 'codex' : 'claude',
157
+ command_kind: 'continue',
158
+ });
159
+ markReady();
160
+ };
150
161
  const source = typeof loadSource === 'function'
151
162
  ? loadSource(claim.source_message_id)
152
163
  : null;
@@ -295,7 +306,7 @@ async function executeCleanResumeClaim(claim, {
295
306
  text: 'continue',
296
307
  sourceMsgId: codex ? null : source.msg_id,
297
308
  expectedProcess: spawned.process,
298
- onDispatched: markReady,
309
+ onDispatched: markContinuationDispatched,
299
310
  });
300
311
  markReady();
301
312
  } catch (error) {
@@ -3,8 +3,10 @@
3
3
  async function prepareCleanRetirement({
4
4
  pm,
5
5
  deliveryBarrier,
6
+ awaitIngressSettlement,
6
7
  awaitHandlerSettlement,
7
8
  settlementTimeoutMs = 30_000,
9
+ qualificationExpectation,
8
10
  } = {}) {
9
11
  if (typeof deliveryBarrier?.fenceAndDrain !== 'function'
10
12
  || typeof deliveryBarrier?.inspect !== 'function') {
@@ -13,22 +15,118 @@ async function prepareCleanRetirement({
13
15
  if (typeof pm?.retireForCleanRestart !== 'function') {
14
16
  throw new TypeError('ProcessManager.retireForCleanRestart is required');
15
17
  }
18
+ if (typeof awaitIngressSettlement !== 'function') {
19
+ throw new TypeError('clean shutdown requires ingress settlement');
20
+ }
16
21
  if (typeof awaitHandlerSettlement !== 'function') {
17
22
  throw new TypeError('clean shutdown requires handler settlement');
18
23
  }
19
24
 
20
- const deliveryDrain = deliveryBarrier.fenceAndDrain();
21
- const processRetirement = pm.retireForCleanRestart({
25
+ const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
26
+ const processRetirement = captureSettlement(() => pm.retireForCleanRestart({
22
27
  getDeliveryEvidence: (sessionKey, sourceMsgId) => (
23
28
  deliveryBarrier.inspect(sessionKey, sourceMsgId)
24
29
  ),
25
- });
26
- const [, snapshots] = await Promise.all([
30
+ ...(qualificationExpectation !== undefined
31
+ ? { qualificationExpectation }
32
+ : {}),
33
+ }));
34
+ const [deliveryResult, retirementResult] = await Promise.allSettled([
27
35
  deliveryDrain,
28
36
  processRetirement,
29
37
  ]);
30
- await awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs });
31
- return { snapshots: Array.isArray(snapshots) ? snapshots : [] };
38
+
39
+ let fallbackResult = null;
40
+ if (retirementResult.status === 'rejected') {
41
+ fallbackResult = await settleOne(() => {
42
+ if (typeof pm?.shutdown !== 'function') {
43
+ throw new TypeError('ProcessManager.shutdown fallback is required');
44
+ }
45
+ return pm.shutdown();
46
+ });
47
+ }
48
+
49
+ const ingressResult = await settleOne(() => (
50
+ awaitIngressSettlement({ timeoutMs: settlementTimeoutMs })
51
+ ));
52
+ const handlerResult = await settleOne(() => (
53
+ awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs })
54
+ ));
55
+
56
+ throwFirstRejected([
57
+ retirementResult,
58
+ deliveryResult,
59
+ fallbackResult,
60
+ ingressResult,
61
+ handlerResult,
62
+ ]);
63
+ const snapshots = retirementResult.value;
64
+ const normalizedSnapshots = Array.isArray(snapshots) ? snapshots : [];
65
+ return {
66
+ snapshots: normalizedSnapshots,
67
+ qualification: Object.hasOwn(normalizedSnapshots, 'qualification')
68
+ ? normalizedSnapshots.qualification
69
+ : null,
70
+ };
71
+ }
72
+
73
+ async function settleCrashShutdown({
74
+ pm,
75
+ deliveryBarrier,
76
+ awaitIngressSettlement,
77
+ awaitHandlerSettlement,
78
+ settlementTimeoutMs = 30_000,
79
+ } = {}) {
80
+ if (typeof deliveryBarrier?.fenceAndDrain !== 'function') {
81
+ throw new TypeError('crash shutdown requires a delivery barrier');
82
+ }
83
+ if (typeof pm?.shutdown !== 'function') {
84
+ throw new TypeError('ProcessManager.shutdown is required');
85
+ }
86
+ if (typeof awaitIngressSettlement !== 'function') {
87
+ throw new TypeError('crash shutdown requires ingress settlement');
88
+ }
89
+ if (typeof awaitHandlerSettlement !== 'function') {
90
+ throw new TypeError('crash shutdown requires handler settlement');
91
+ }
92
+
93
+ const deliveryDrain = captureSettlement(() => deliveryBarrier.fenceAndDrain());
94
+ const processShutdown = captureSettlement(() => pm.shutdown());
95
+ const [deliveryResult, shutdownResult] = await Promise.allSettled([
96
+ deliveryDrain,
97
+ processShutdown,
98
+ ]);
99
+ const ingressResult = await settleOne(() => (
100
+ awaitIngressSettlement({ timeoutMs: settlementTimeoutMs })
101
+ ));
102
+ const handlerResult = await settleOne(() => (
103
+ awaitHandlerSettlement({ timeoutMs: settlementTimeoutMs })
104
+ ));
105
+
106
+ throwFirstRejected([
107
+ shutdownResult,
108
+ deliveryResult,
109
+ ingressResult,
110
+ handlerResult,
111
+ ]);
112
+ }
113
+
114
+ function captureSettlement(operation) {
115
+ try {
116
+ return Promise.resolve(operation());
117
+ } catch (error) {
118
+ return Promise.reject(error);
119
+ }
120
+ }
121
+
122
+ async function settleOne(operation) {
123
+ const [result] = await Promise.allSettled([captureSettlement(operation)]);
124
+ return result;
125
+ }
126
+
127
+ function throwFirstRejected(results) {
128
+ const rejected = results.find((result) => result?.status === 'rejected');
129
+ if (rejected) throw rejected.reason;
32
130
  }
33
131
 
34
132
  function buildResumeIntents({
@@ -116,5 +214,6 @@ function buildResumeIntents({
116
214
 
117
215
  module.exports = {
118
216
  prepareCleanRetirement,
217
+ settleCrashShutdown,
119
218
  buildResumeIntents,
120
219
  };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7
+ const PACKAGE_VERSION_RE = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,127}$/;
8
+
9
+ function createDaemonIdentity({
10
+ mainPath,
11
+ packageVersion,
12
+ pid = process.pid,
13
+ randomUUID = crypto.randomUUID,
14
+ realpathSync = fs.realpathSync,
15
+ } = {}) {
16
+ if (typeof mainPath !== 'string' || mainPath.length === 0) {
17
+ throw new TypeError('daemon main path is required');
18
+ }
19
+ if (!PACKAGE_VERSION_RE.test(packageVersion || '')) {
20
+ throw new TypeError('daemon package version is invalid');
21
+ }
22
+ if (!Number.isSafeInteger(pid) || pid <= 1) {
23
+ throw new TypeError('daemon PID is invalid');
24
+ }
25
+
26
+ const daemonInstanceId = randomUUID();
27
+ if (!UUID_RE.test(daemonInstanceId)) {
28
+ throw new TypeError('daemon instance identity is invalid');
29
+ }
30
+ const mainRealpath = realpathSync(mainPath);
31
+
32
+ return Object.freeze({
33
+ pid,
34
+ daemon_instance_id: daemonInstanceId,
35
+ package_version: packageVersion,
36
+ main_realpath_sha256: crypto.createHash('sha256')
37
+ .update(mainRealpath)
38
+ .digest('hex'),
39
+ });
40
+ }
41
+
42
+ module.exports = { createDaemonIdentity };
@@ -9,11 +9,18 @@ function parseSystemdInvocationId(env = process.env) {
9
9
  return value;
10
10
  }
11
11
 
12
- function lifecycleDetail(detail, invocationId) {
12
+ function lifecycleDetail(detail, invocationId, daemonIdentity = null) {
13
13
  const value = detail && typeof detail === 'object' ? detail : {};
14
- return invocationId == null
14
+ const withDaemonIdentity = daemonIdentity == null
15
15
  ? value
16
- : { ...value, invocation_id: invocationId };
16
+ : {
17
+ ...value,
18
+ daemon_instance_id: daemonIdentity.daemon_instance_id,
19
+ pid: daemonIdentity.pid,
20
+ };
21
+ return invocationId == null
22
+ ? withDaemonIdentity
23
+ : { ...withDaemonIdentity, invocation_id: invocationId };
17
24
  }
18
25
 
19
26
  module.exports = {
@@ -90,6 +90,7 @@ const richSection = (inlineMedia) => [
90
90
  '- A numbered sequence (steps, ranked facts, top-N) → a real ordered list (`1.` at line start, continuation lines indented under their item), not bold pseudo-items like `**1. First thing.**` on their own paragraphs. A real list renders with native numbering and hanging indentation; the bold fake renders as flat paragraphs that merely start with a digit.',
91
91
  '- A list of items each with a yes/no/pending state (approve this? confirm that?) → a real task list (`- [ ] item`), not `• item` with an emoji glued on.',
92
92
  '- A comparison or key/value summary → a real markdown table.',
93
+ '- Rows and columns you are laying out yourself → a real markdown table, never hand-aligned columns inside a ``` fence. A fence is not a table: its columns are just whitespace you typed, so the client cannot re-lay-out them to fit and on a narrow screen the rows break mid-row and the alignment collapses into noise. Keep fences for code, logs, and command output you are quoting verbatim — including output that is already table-shaped, which you reproduce exactly rather than retype as a markdown table.',
93
94
  '- A quoted aside, caveat, or callout you want visually set apart → a real blockquote (`> like this`), not a bold prefix.',
94
95
  '- A visual break between distinct sections of a long reply → a real divider (`---` on its own line), the same way you already reach for it, but now it renders as a divider instead of literal dashes.',
95
96
  '- Verbose supporting detail (a diff, a log, raw output) you want available but not front-and-center → `<details><summary>Label</summary>...</details>`.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.37.2",
3
+ "version": "0.38.1",
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": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.14",
66
+ "@shumkov/orchestra": "0.10.16",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",