@a1hvdy/cc-openclaw 0.27.0 → 0.27.2

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.
@@ -23,6 +23,7 @@
23
23
  import { CallbackMap } from './callback-mapping.js';
24
24
  import { sendTg, editTg, telegramApi } from '../../lib/telegram-bot-api.js';
25
25
  import { escapeHtml } from '../../lib/html-render.js';
26
+ import { probeInjectionEnqueued } from '../../lib/probes.js';
26
27
  /** Namespace prefix for callback_data so api.registerInteractiveHandler routes
27
28
  * taps here. Matched at the first ':' by the gateway (must be [A-Za-z0-9._-]+). */
28
29
  export const ASKUSER_NS = 'ccmirror';
@@ -165,6 +166,7 @@ function injectAnswer(api, ctx, text) {
165
166
  return;
166
167
  }
167
168
  try {
169
+ probeInjectionEnqueued(sessionKey, 'askuser'); // P0-A (observe-only, gated)
168
170
  api.enqueueNextTurnInjection({
169
171
  sessionKey,
170
172
  text: `[User answered the AskUserQuestion]: ${text}`,
@@ -98,17 +98,21 @@ export function handleNew(ctx) {
98
98
  };
99
99
  }
100
100
  const existing = getBySlug(slug);
101
- // Session-name comes from the engine; M4 stores a placeholder that M5+ overwrites.
101
+ // Session-name comes from the engine when a real turn fronts this slug; until
102
+ // then we store a placeholder. The registry IS the real state that /sessions
103
+ // and /status read — so the confirmation reflects the actual registry count
104
+ // (planning D-5/D-2), not a false "engine wire-up lands in M5" promise.
102
105
  const sessionName = existing?.sessionName ?? `pending-${slug}-${Date.now()}`;
103
106
  register(slug, sessionName);
107
+ const total = list().length;
104
108
  return {
105
109
  actions: [
106
110
  {
107
111
  type: 'sendMessage',
108
112
  chat_id: ctx.chatId,
109
113
  text: existing
110
- ? `Session "${slug}" already registered fronted.`
111
- : `Session "${slug}" registered. Engine wire-up lands in M5.`,
114
+ ? `Session "${slug}" already registered (${total} total). Open it from /sessions.`
115
+ : `Session "${slug}" registered (${total} total). Open it from /sessions.`,
112
116
  },
113
117
  ],
114
118
  };
@@ -128,13 +132,14 @@ export function handleStop(ctx) {
128
132
  };
129
133
  }
130
134
  const removed = unregister(slug);
135
+ const remaining = list().length;
131
136
  return {
132
137
  actions: [
133
138
  {
134
139
  type: 'sendMessage',
135
140
  chat_id: ctx.chatId,
136
141
  text: removed
137
- ? `Session "${slug}" stopped.`
142
+ ? `Session "${slug}" stopped (${remaining} remaining).`
138
143
  : `No registered session named "${slug}".`,
139
144
  },
140
145
  ],
@@ -166,16 +171,17 @@ export function handleStatus(ctx) {
166
171
  }
167
172
  // ── /compact ─────────────────────────────────────────────────────────────
168
173
  export function handleCompact(ctx) {
169
- // M4: surface the intent. Actual context-compaction wiring runs through
170
- // the cc-handler module (existing /cc compact path)bridging the
171
- // mirror to that handler lands in M5 alongside the rest of the engine
172
- // integration. Without the bridge, the user sees a clear "queued" state.
174
+ // D-6 (planning): honest stub. The Telegram bridge has no session-control
175
+ // primitive to trigger context compaction on the running session the only
176
+ // plugin levers are enqueueNextTurnInjection (text, next-turn only) and
177
+ // registerInteractiveHandler. So /compact is CLI-only until/unless OpenClaw
178
+ // exposes a control primitive. Claiming "queued" would be a lie (it never runs).
173
179
  return {
174
180
  actions: [
175
181
  {
176
182
  type: 'sendMessage',
177
183
  chat_id: ctx.chatId,
178
- text: '⏳ Compact queuedengine wire-up lands in M5.',
184
+ text: "⚠️ /compact is CLI-only the Telegram bridge can't trigger context compaction (no session-control primitive). Run it from Claude Code directly.",
179
185
  },
180
186
  ],
181
187
  };
@@ -212,14 +218,15 @@ export function handleCost(ctx) {
212
218
  }
213
219
  // ── /rewind ──────────────────────────────────────────────────────────────
214
220
  export function handleRewind(ctx) {
215
- // M4: queuedactual rewind walks the cc-handler resume path (deferred
216
- // to M5 with the rest of engine bridging).
221
+ // D-6 (planning): honest stub same rationale as /compact. Rewinding a
222
+ // running session needs a session-control primitive the plugin can't reach;
223
+ // it's CLI-only. "Queued" would never actually run, so we say so plainly.
217
224
  return {
218
225
  actions: [
219
226
  {
220
227
  type: 'sendMessage',
221
228
  chat_id: ctx.chatId,
222
- text: '⏪ Rewind queuedengine wire-up lands in M5.',
229
+ text: "⚠️ /rewind is CLI-only the Telegram bridge can't rewind a session (no session-control primitive). Run it from Claude Code directly.",
223
230
  },
224
231
  ],
225
232
  };
@@ -22,9 +22,14 @@
22
22
  * Single shared CallbackMap + ComposeBuffer per process so callback
23
23
  * resolution and compose state survive across handler invocations.
24
24
  */
25
+ import { type TelegramAction } from './commands.js';
25
26
  import { CallbackMap } from './callback-mapping.js';
26
27
  import { ComposeBuffer } from './compose-buffer.js';
27
28
  import { type InteractiveCtx, type InjectApi } from './askuser.js';
29
+ interface InboundLogger {
30
+ info: (msg: string) => void;
31
+ warn: (msg: string) => void;
32
+ }
28
33
  export interface InboundHandlerApi {
29
34
  on(event: string, handler: (...args: unknown[]) => unknown | Promise<unknown>): void;
30
35
  logger?: {
@@ -50,6 +55,19 @@ export interface HandlerState {
50
55
  composeBuffer: ComposeBuffer;
51
56
  }
52
57
  export declare function createHandlerState(): HandlerState;
58
+ /**
59
+ * Forward a single TelegramAction to the actual Telegram API. Returns
60
+ * the API response (or {ok:false} on failure). Pure I/O — no state
61
+ * mutation. Exported for unit testing (planning M-B/B2).
62
+ *
63
+ * planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
64
+ * multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
65
+ * sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
66
+ * milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
67
+ * dormant forwarding infrastructure, not yet a user-reachable feature.
68
+ */
69
+ export declare function forwardAction(action: TelegramAction, threadId: number | undefined, logger: InboundLogger): Promise<void>;
53
70
  /** Test-only — reset module-level dispatch + card state. */
54
71
  export declare function _resetSubscriptionForTests(): void;
55
72
  export declare function registerInboundHandler(api: InboundHandlerApi, state?: HandlerState): HandlerState;
73
+ export {};
@@ -23,13 +23,14 @@
23
23
  * resolution and compose state survive across handler invocations.
24
24
  */
25
25
  import { dispatchCommand, parseSlash, COMMAND_HANDLERS } from './commands.js';
26
- import { sendTg, editTg } from '../../lib/telegram-bot-api.js';
26
+ import { sendTg, editTg, sendDocumentTg } from '../../lib/telegram-bot-api.js';
27
27
  import { CallbackMap } from './callback-mapping.js';
28
28
  import { ComposeBuffer } from './compose-buffer.js';
29
29
  import { TurnStateMachine } from './state-machine.js';
30
30
  import { renderTurn } from './card-renderer.js';
31
31
  import { cardState as _cardState, cardStateDebug } from './card-state.js';
32
32
  import { handleTap, handleTapData, isAskUserCallback, rememberSessionKey, ASKUSER_NS, } from './askuser.js';
33
+ import { probeInboundShape, probeToolUse } from '../../lib/probes.js';
33
34
  const PLUGIN_TAG = '[cc-openclaw/telegram-mirror/inbound]';
34
35
  // v0.26.3 M5 — register the AskUserQuestion interactive tap handler exactly
35
36
  // once per process (registerInboundHandler runs on every register() call).
@@ -76,12 +77,15 @@ const MIRROR_COMMANDS = new Set(Object.keys(COMMAND_HANDLERS));
76
77
  /**
77
78
  * Forward a single TelegramAction to the actual Telegram API. Returns
78
79
  * the API response (or {ok:false} on failure). Pure I/O — no state
79
- * mutation.
80
+ * mutation. Exported for unit testing (planning M-B/B2).
80
81
  *
81
- * editMessageText and sendDocument variants land in v0.25.2 when the
82
- * render pipeline and plan-attachment dispatch wire up.
82
+ * planning M-B/B2 (D-3): the `sendDocument` branch is now wired to the
83
+ * multipart `sendDocumentTg` helper (was a no-op warn). NOTE: the PRODUCER of
84
+ * sendDocument actions — ExitPlanMode detection → buildPlanAttachment — is
85
+ * milestone M-B/B3, deferred pending probe P0-B. Until B3 lands this branch is
86
+ * dormant forwarding infrastructure, not yet a user-reachable feature.
83
87
  */
84
- async function forwardAction(action, threadId, logger) {
88
+ export async function forwardAction(action, threadId, logger) {
85
89
  try {
86
90
  if (action.type === 'sendMessage') {
87
91
  await sendTg(String(action.chat_id), action.text, threadId !== undefined ? String(threadId) : undefined, action.reply_markup);
@@ -91,8 +95,15 @@ async function forwardAction(action, threadId, logger) {
91
95
  await editTg(String(action.chat_id), action.message_id, action.text, action.reply_markup);
92
96
  return;
93
97
  }
94
- // sendDocument: v0.25.2 plan-mode attachment wire-up.
95
- logger.warn(`${PLUGIN_TAG} action type "${action.type}" not yet forwarded (deferred to v0.25.2)`);
98
+ if (action.type === 'sendDocument') {
99
+ await sendDocumentTg(String(action.chat_id), action.filename, action.content, {
100
+ caption: action.caption,
101
+ replyMarkup: action.reply_markup,
102
+ threadId,
103
+ });
104
+ return;
105
+ }
106
+ logger.warn(`${PLUGIN_TAG} action type "${action.type}" not forwarded (no handler)`);
96
107
  }
97
108
  catch (err) {
98
109
  logger.warn(`${PLUGIN_TAG} forwardAction failed: ${err.message}`);
@@ -205,6 +216,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
205
216
  // Per-event-id dedup at dispatch layer (purpose=slash).
206
217
  if (_seenOrMark('slash', _eventId(event)))
207
218
  return undefined;
219
+ probeInboundShape(event); // P0-C inbound surface (observe-only, gated)
208
220
  // Extract text from the canonical (2026.5.x) `event.content` field;
209
221
  // fall back to legacy nested paths if a future gateway version reverts.
210
222
  const text = (typeof event.content === 'string' ? event.content : undefined) ??
@@ -413,6 +425,7 @@ export function registerInboundHandler(api, state = createHandlerState()) {
413
425
  const event = args[0];
414
426
  dumpShapeOnce('before_tool_call', event);
415
427
  const ev = event;
428
+ probeToolUse(ev); // P0-B ExitPlanMode detection (observe-only, gated)
416
429
  const evId = _eventId({ sessionKey: (ev?.sessionKey ?? ev?.ctx?.['SessionKey']), timestamp: ev?.timestamp, content: `tool_use:${String((ev?.id ?? ev?.tool_use_id ?? ''))}` });
417
430
  if (_seenOrMark('tool_use', evId))
418
431
  return undefined;
@@ -102,6 +102,8 @@ export class BaseOneShotSession extends EventEmitter {
102
102
  isReady: this._isReady,
103
103
  startTime: this._startTime,
104
104
  lastActivity: this._stats.lastActivity,
105
+ // v0.27.x — oneshot engine doesn't separate progress; mirror lastActivity.
106
+ lastProgressAt: this._stats.lastActivity,
105
107
  contextPercent: 0,
106
108
  retries: 0,
107
109
  sessionId: this.sessionId,
@@ -797,6 +797,9 @@ export class PersistentCustomSession extends EventEmitter {
797
797
  isReady: this._isReady,
798
798
  startTime: this._startTime,
799
799
  lastActivity: this._stats.lastActivity,
800
+ // v0.27.x — custom engine doesn't separate progress from activity; mirror
801
+ // lastActivity so the shared watchdog behaves exactly as before for it.
802
+ lastProgressAt: this._stats.lastActivity,
800
803
  contextPercent: this.engineConfig.persistent
801
804
  ? Math.min(100, Math.round(((this._stats.tokensIn + this._stats.tokensOut) / ctxWindow) * 100))
802
805
  : 0,
@@ -25,6 +25,8 @@ interface InternalStats {
25
25
  costUsd: number;
26
26
  startTime: string | null;
27
27
  lastActivity: string | null;
28
+ /** v0.27.x — last PROGRESS event ts (excludes api_retry); watchdog keys off it. */
29
+ lastProgressAt: string | null;
28
30
  history: Array<{
29
31
  time: string;
30
32
  type: string;
@@ -58,6 +58,7 @@ export class PersistentClaudeSession extends EventEmitter {
58
58
  costUsd: 0,
59
59
  startTime: null,
60
60
  lastActivity: null,
61
+ lastProgressAt: null,
61
62
  history: [],
62
63
  retries: 0,
63
64
  lastRetryError: undefined,
@@ -346,6 +347,16 @@ export class PersistentClaudeSession extends EventEmitter {
346
347
  _handleEvent(event) {
347
348
  const type = event.type;
348
349
  this.stats.lastActivity = new Date().toISOString();
350
+ // v0.27.x — progress signal for the stalled-session watchdog. Every event
351
+ // EXCEPT `system/api_retry` counts as progress. api_retry fires during an
352
+ // API retry-storm WITHOUT producing output; counting it as activity (as
353
+ // lastActivity does) defeated the watchdog — it never saw 180s of
354
+ // no-progress, so a stalled/retrying model call hung to the 900s envelope
355
+ // (the gateway's `recovery=none lastProgressAge=353s`). Keying the watchdog
356
+ // off lastProgressAt fast-fails it at the threshold instead.
357
+ const isApiRetry = type === 'system' && event.subtype === 'api_retry';
358
+ if (!isApiRetry)
359
+ this.stats.lastProgressAt = this.stats.lastActivity;
349
360
  // Track history (keep last 100)
350
361
  this.stats.history.push({ time: this.stats.lastActivity, type, event });
351
362
  if (this.stats.history.length > MAX_HISTORY_ITEMS)
@@ -769,6 +780,7 @@ export class PersistentClaudeSession extends EventEmitter {
769
780
  isReady: this._isReady,
770
781
  startTime: this.stats.startTime,
771
782
  lastActivity: this.stats.lastActivity,
783
+ lastProgressAt: this.stats.lastProgressAt,
772
784
  // v0.6.0: contextPercent now reflects ACTUAL per-turn context occupancy
773
785
  // (input + cache-read tokens from the last `result` event), not lifetime
774
786
  // cumulative tokens. Pre-fix it saturated at 100% by turn 3 of any
@@ -0,0 +1,50 @@
1
+ /**
2
+ * src/lib/probes.ts — Phase-0 empirical probe instrumentation (planning P0-A/B/C).
3
+ *
4
+ * OBSERVE-ONLY. Gated by `CC_OPENCLAW_PROBE=1` so it is completely silent — zero
5
+ * behavior change, no log output — in normal operation. The operator (A1)
6
+ * enables it for a single probe session, exercises the relevant Telegram
7
+ * interaction, then greps stderr for the `[cc-openclaw/probe]` markers. The
8
+ * runbook (PROBES-RUNBOOK in the planning dir) has the exact steps + how to read
9
+ * the results.
10
+ *
11
+ * These resolve the load-bearing seams that CANNOT be read from source because
12
+ * they depend on OpenClaw gateway runtime behavior:
13
+ * P0-A: does `enqueueNextTurnInjection` trigger a run, or only stage context
14
+ * for the next user message? (decides feature #1 Approve + #3 /send)
15
+ * P0-B: does an `ExitPlanMode` tool_use ever fire on the bypassPermissions
16
+ * Telegram path? (decides feature #1's trigger)
17
+ * P0-C: does a Telegram photo reach the plugin as an image block — at
18
+ * before_dispatch and/or in the openai-compat request body (where
19
+ * message-extractor strips non-text parts) — or is it gateway-stripped?
20
+ * (decides whether feature #2 is plugin-side feasible at all)
21
+ */
22
+ /**
23
+ * P0-A — log each `enqueueNextTurnInjection` call site. The operator correlates
24
+ * this with whether a reply arrives in Telegram WITHOUT typing a follow-up
25
+ * message: if it does, injection triggers a run; if not, it only stages context.
26
+ */
27
+ export declare function probeInjectionEnqueued(sessionKey: string, source: string): void;
28
+ /**
29
+ * P0-B — log when a tool_use is `ExitPlanMode` (and any other tool name, for
30
+ * context). Looks across the known event field paths, mirroring
31
+ * inbound-handler.extractToolUse so it works whatever shape the gateway uses.
32
+ */
33
+ export declare function probeToolUse(ev: Record<string, unknown> | undefined): void;
34
+ /**
35
+ * P0-C (inbound surface) — dump the before_dispatch event, flagging whether it
36
+ * carries any media field, so the operator sees whether photo/document surface
37
+ * to the plugin at all.
38
+ */
39
+ export declare function probeInboundShape(event: unknown): void;
40
+ /**
41
+ * P0-C (openai-compat body) — the PRECISE probe. Does an image block survive to
42
+ * the request body, where `message-extractor.ts` strips non-text parts? Logs
43
+ * each non-text content-part type. If image parts appear here, feature #2 is
44
+ * feasible plugin-side (preserve them through extractUserMessage); if nothing
45
+ * non-text ever appears, the image is gateway-stripped upstream → hands-off-blocked.
46
+ */
47
+ export declare function probeMultimodalContent(messages: Array<{
48
+ role?: string;
49
+ content?: unknown;
50
+ }> | undefined): void;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * src/lib/probes.ts — Phase-0 empirical probe instrumentation (planning P0-A/B/C).
3
+ *
4
+ * OBSERVE-ONLY. Gated by `CC_OPENCLAW_PROBE=1` so it is completely silent — zero
5
+ * behavior change, no log output — in normal operation. The operator (A1)
6
+ * enables it for a single probe session, exercises the relevant Telegram
7
+ * interaction, then greps stderr for the `[cc-openclaw/probe]` markers. The
8
+ * runbook (PROBES-RUNBOOK in the planning dir) has the exact steps + how to read
9
+ * the results.
10
+ *
11
+ * These resolve the load-bearing seams that CANNOT be read from source because
12
+ * they depend on OpenClaw gateway runtime behavior:
13
+ * P0-A: does `enqueueNextTurnInjection` trigger a run, or only stage context
14
+ * for the next user message? (decides feature #1 Approve + #3 /send)
15
+ * P0-B: does an `ExitPlanMode` tool_use ever fire on the bypassPermissions
16
+ * Telegram path? (decides feature #1's trigger)
17
+ * P0-C: does a Telegram photo reach the plugin as an image block — at
18
+ * before_dispatch and/or in the openai-compat request body (where
19
+ * message-extractor strips non-text parts) — or is it gateway-stripped?
20
+ * (decides whether feature #2 is plugin-side feasible at all)
21
+ */
22
+ const TAG = '[cc-openclaw/probe]';
23
+ /** Read on every call so the operator can flip it without restarting. */
24
+ function probeOn() {
25
+ return process.env.CC_OPENCLAW_PROBE === '1';
26
+ }
27
+ function emit(line) {
28
+ // stderr so PM2 captures it regardless of stdout-only filtering.
29
+ process.stderr.write(`${TAG} ${line} ts=${Date.now()}\n`);
30
+ }
31
+ /**
32
+ * P0-A — log each `enqueueNextTurnInjection` call site. The operator correlates
33
+ * this with whether a reply arrives in Telegram WITHOUT typing a follow-up
34
+ * message: if it does, injection triggers a run; if not, it only stages context.
35
+ */
36
+ export function probeInjectionEnqueued(sessionKey, source) {
37
+ if (!probeOn())
38
+ return;
39
+ emit(`P0-A injection-enqueued source=${source} sessionKey=${sessionKey}`);
40
+ }
41
+ /**
42
+ * P0-B — log when a tool_use is `ExitPlanMode` (and any other tool name, for
43
+ * context). Looks across the known event field paths, mirroring
44
+ * inbound-handler.extractToolUse so it works whatever shape the gateway uses.
45
+ */
46
+ export function probeToolUse(ev) {
47
+ if (!probeOn() || !ev)
48
+ return;
49
+ const tool = ev.tool;
50
+ const name = ev.toolName ??
51
+ tool?.['name'] ??
52
+ ev.name;
53
+ if (name === 'ExitPlanMode')
54
+ emit('P0-B ExitPlanMode-fired');
55
+ else if (name)
56
+ emit(`P0-B tool_use name=${name}`);
57
+ }
58
+ /**
59
+ * P0-C (inbound surface) — dump the before_dispatch event, flagging whether it
60
+ * carries any media field, so the operator sees whether photo/document surface
61
+ * to the plugin at all.
62
+ */
63
+ export function probeInboundShape(event) {
64
+ if (!probeOn())
65
+ return;
66
+ try {
67
+ const ev = event;
68
+ const msg = ev?.raw?.message;
69
+ const hasMedia = !!(msg && (msg.photo || msg.document || msg.video || msg.voice || msg.audio || msg.sticker));
70
+ const dump = JSON.stringify(event, (_k, v) => (typeof v === 'function' ? '[fn]' : v));
71
+ emit(`P0-C before_dispatch hasMedia=${hasMedia} shape=${dump.slice(0, 1200)}`);
72
+ }
73
+ catch (err) {
74
+ emit(`P0-C inbound dump failed: ${err.message}`);
75
+ }
76
+ }
77
+ /**
78
+ * P0-C (openai-compat body) — the PRECISE probe. Does an image block survive to
79
+ * the request body, where `message-extractor.ts` strips non-text parts? Logs
80
+ * each non-text content-part type. If image parts appear here, feature #2 is
81
+ * feasible plugin-side (preserve them through extractUserMessage); if nothing
82
+ * non-text ever appears, the image is gateway-stripped upstream → hands-off-blocked.
83
+ */
84
+ export function probeMultimodalContent(messages) {
85
+ if (!probeOn() || !messages)
86
+ return;
87
+ for (const m of messages) {
88
+ if (!Array.isArray(m.content))
89
+ continue;
90
+ const parts = m.content;
91
+ const nonText = parts.filter((p) => p && p.type && p.type !== 'text').map((p) => p.type);
92
+ if (nonText.length > 0) {
93
+ emit(`P0-C openai-body role=${m.role ?? '?'} nonTextParts=${nonText.join(',')}`);
94
+ }
95
+ }
96
+ }
@@ -98,3 +98,33 @@ export declare function sendTg(chatId: string | number, text: string, threadId?:
98
98
  * plain-text fallback.
99
99
  */
100
100
  export declare function editTg(chatId: string | number, messageId: number, text: string, replyMarkup?: unknown): Promise<TelegramApiResponse>;
101
+ export interface SendDocumentOptions {
102
+ caption?: string;
103
+ parseMode?: 'HTML' | 'MarkdownV2';
104
+ threadId?: string | number;
105
+ replyMarkup?: unknown;
106
+ }
107
+ /**
108
+ * Build a multipart/form-data body for sendDocument. PURE — no I/O — so the
109
+ * encoding (the R-3 risk) is unit-testable without a network round-trip.
110
+ *
111
+ * The document is sent as an inline InputFile (Content-Type text/markdown). The
112
+ * boundary MUST NOT appear in any field value or the file content; callers use a
113
+ * random 16-byte boundary (sendDocumentTg) so collision is astronomically
114
+ * unlikely against Markdown plan bodies.
115
+ */
116
+ export declare function buildDocumentMultipart(opts: {
117
+ boundary: string;
118
+ chatId: string | number;
119
+ filename: string;
120
+ content: string;
121
+ caption?: string;
122
+ parseMode?: 'HTML' | 'MarkdownV2';
123
+ threadId?: string | number;
124
+ replyMarkup?: unknown;
125
+ }): Buffer;
126
+ /**
127
+ * Upload a text document (e.g. a plan .md) to a chat via sendDocument. Returns
128
+ * the API response, or {ok:false} on network/encoding failure (never throws).
129
+ */
130
+ export declare function sendDocumentTg(chatId: string | number, filename: string, content: string, opts?: SendDocumentOptions): Promise<TelegramApiResponse>;
@@ -27,6 +27,7 @@ import { request as httpsRequest } from 'node:https';
27
27
  import { readFileSync } from 'node:fs';
28
28
  import { homedir } from 'node:os';
29
29
  import { join } from 'node:path';
30
+ import { randomBytes } from 'node:crypto';
30
31
  export const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
31
32
  const PLUGIN_TAG = '[cc-openclaw/telegram-bot-api]';
32
33
  // ─── Bot token state ───────────────────────────────────────────────────────
@@ -201,3 +202,89 @@ export async function editTg(chatId, messageId, text, replyMarkup) {
201
202
  return { ok: false };
202
203
  }
203
204
  }
205
+ /**
206
+ * Build a multipart/form-data body for sendDocument. PURE — no I/O — so the
207
+ * encoding (the R-3 risk) is unit-testable without a network round-trip.
208
+ *
209
+ * The document is sent as an inline InputFile (Content-Type text/markdown). The
210
+ * boundary MUST NOT appear in any field value or the file content; callers use a
211
+ * random 16-byte boundary (sendDocumentTg) so collision is astronomically
212
+ * unlikely against Markdown plan bodies.
213
+ */
214
+ export function buildDocumentMultipart(opts) {
215
+ const { boundary } = opts;
216
+ const parts = [];
217
+ const textField = (name, value) => {
218
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, 'utf8'));
219
+ };
220
+ textField('chat_id', String(opts.chatId));
221
+ if (opts.caption)
222
+ textField('caption', opts.caption);
223
+ if (opts.parseMode)
224
+ textField('parse_mode', opts.parseMode);
225
+ if (opts.threadId !== undefined)
226
+ textField('message_thread_id', String(opts.threadId));
227
+ if (opts.replyMarkup)
228
+ textField('reply_markup', JSON.stringify(opts.replyMarkup));
229
+ // The document file part — header, then raw content, then CRLF.
230
+ parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="document"; filename="${opts.filename}"\r\nContent-Type: text/markdown\r\n\r\n`, 'utf8'));
231
+ parts.push(Buffer.from(opts.content, 'utf8'));
232
+ parts.push(Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'));
233
+ return Buffer.concat(parts);
234
+ }
235
+ /** Low-level multipart POST. Mirrors `telegramApi` but sets a multipart
236
+ * Content-Type + a Buffer body. */
237
+ function telegramApiMultipart(method, boundary, body) {
238
+ return new Promise((resolve, reject) => {
239
+ const options = {
240
+ hostname: 'api.telegram.org',
241
+ path: `/bot${_botToken}/${method}`,
242
+ method: 'POST',
243
+ headers: {
244
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
245
+ 'Content-Length': body.length,
246
+ },
247
+ };
248
+ const req = httpsRequest(options, (res) => {
249
+ let data = '';
250
+ res.on('data', (chunk) => (data += chunk));
251
+ res.on('end', () => {
252
+ try {
253
+ resolve(JSON.parse(data));
254
+ }
255
+ catch {
256
+ resolve({ ok: false, description: 'JSON parse error' });
257
+ }
258
+ });
259
+ });
260
+ req.on('error', (err) => reject(err));
261
+ req.setTimeout(15_000, () => {
262
+ req.destroy(new Error('Telegram API timeout'));
263
+ });
264
+ req.write(body);
265
+ req.end();
266
+ });
267
+ }
268
+ /**
269
+ * Upload a text document (e.g. a plan .md) to a chat via sendDocument. Returns
270
+ * the API response, or {ok:false} on network/encoding failure (never throws).
271
+ */
272
+ export async function sendDocumentTg(chatId, filename, content, opts = {}) {
273
+ try {
274
+ const boundary = `----ccopenclaw${randomBytes(16).toString('hex')}`;
275
+ const body = buildDocumentMultipart({
276
+ boundary,
277
+ chatId,
278
+ filename,
279
+ content,
280
+ caption: opts.caption,
281
+ parseMode: opts.parseMode,
282
+ threadId: opts.threadId,
283
+ replyMarkup: opts.replyMarkup,
284
+ });
285
+ return await telegramApiMultipart('sendDocument', boundary, body);
286
+ }
287
+ catch {
288
+ return { ok: false };
289
+ }
290
+ }
@@ -32,6 +32,7 @@ import { serializeToolResults, serializeToolResultsAsBlocks, } from './tool-resu
32
32
  import { isToolStreamMode } from './mode-flags.js';
33
33
  import { detectSlashCommand, maybeInlineSkill } from './skill-resolver.js';
34
34
  import { isOpenaiCompatNewConvoHeuristic } from '../lib/config.js';
35
+ import { probeMultimodalContent } from '../lib/probes.js';
35
36
  /**
36
37
  * Extract the relevant parts from an OpenAI messages array.
37
38
  *
@@ -59,6 +60,9 @@ export function extractUserMessage(messages, headers) {
59
60
  if (!messages || messages.length === 0) {
60
61
  throw new Error('messages array is empty');
61
62
  }
63
+ // P0-C openai-body probe (observe-only, gated): does an image block survive to
64
+ // the request body before textOf() below strips non-text parts? See lib/probes.ts.
65
+ probeMultimodalContent(messages);
62
66
  // Normalize content from any message: OpenAI API allows content as a string
63
67
  // OR an array of content parts (e.g. multimodal messages with text + images).
64
68
  // We need a string for the CLI, so arrays are joined.
@@ -22,9 +22,11 @@
22
22
  export interface WatchdogManagedSession {
23
23
  session: {
24
24
  isBusy: boolean;
25
- /** Returns at least { lastActivity }. Other stats fields are ignored. */
25
+ /** Returns at least { lastActivity, lastProgressAt }. The watchdog prefers
26
+ * lastProgressAt (excludes api_retry pings); other stats fields ignored. */
26
27
  getStats(): {
27
28
  lastActivity?: string | null | undefined;
29
+ lastProgressAt?: string | null | undefined;
28
30
  };
29
31
  stop(): void;
30
32
  };
@@ -46,18 +46,20 @@ export function watchStalledSessions(opts) {
46
46
  if (!managed.session.isBusy)
47
47
  continue;
48
48
  const stats = managed.session.getStats();
49
- const lastActivityIso = stats.lastActivity;
50
- const lastEventMs = lastActivityIso
51
- ? new Date(lastActivityIso).getTime()
52
- : managed.lastActivity;
49
+ // v0.27.x prefer the PROGRESS timestamp (real output: text/tool/result),
50
+ // which excludes `system/api_retry` pings. Keying off lastActivity let a
51
+ // retry-storm reset the clock forever so the watchdog never fired. Fall back
52
+ // to lastActivity, then the SessionManager wall-clock.
53
+ const progressIso = stats.lastProgressAt ?? stats.lastActivity;
54
+ const lastEventMs = progressIso ? new Date(progressIso).getTime() : managed.lastActivity;
53
55
  const ageMs = now - lastEventMs;
54
56
  if (ageMs <= thresholdMs)
55
57
  continue;
56
- opts.logger.warn(`[watchdog] killing stalled session ${name} (busy, no subprocess event for ${Math.round(ageMs / 1000)}s, threshold=${Math.round(thresholdMs / 1000)}s)`);
58
+ opts.logger.warn(`[watchdog] killing stalled session ${name} (busy, no progress for ${Math.round(ageMs / 1000)}s, threshold=${Math.round(thresholdMs / 1000)}s)`);
57
59
  try {
58
60
  trajectory.emit('session_stalled_killed', {
59
61
  ageMs,
60
- lastActivity: lastActivityIso,
62
+ lastProgressAt: progressIso,
61
63
  thresholdMs,
62
64
  model: managed.config.model,
63
65
  cwd: managed.cwd,
@@ -184,6 +184,12 @@ export interface SessionStats {
184
184
  isReady: boolean;
185
185
  startTime: string | null;
186
186
  lastActivity: string | null;
187
+ /** v0.27.x — wall-clock ISO of the last PROGRESS event (text / tool_use /
188
+ * tool_result / thinking / result / init) — i.e. the subprocess produced
189
+ * real output. Distinct from lastActivity, which also moves on non-progress
190
+ * events like `system/api_retry`. The stalled-session watchdog keys off this
191
+ * so an API retry-storm (no output) is fast-failed instead of looking busy. */
192
+ lastProgressAt: string | null;
187
193
  /**
188
194
  * Approximate context window utilization (0-100).
189
195
  * Estimated as (tokensIn + tokensOut) / 200,000 * 100.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a1hvdy/cc-openclaw",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
4
4
  "description": "A1xAI's Anthropic CLI bridge plugin for OpenClaw",
5
5
  "author": "@a1cy",
6
6
  "license": "MIT",