openclaw-lark-multi-agent 1.8.19 → 1.8.21

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.
@@ -75,6 +75,8 @@ export type GoClient = {
75
75
  export type GoControllerDeps = {
76
76
  botName: string;
77
77
  fallbackModel: string;
78
+ /** OpenClaw profile that owns this bot's sessions (default "main"). */
79
+ profile?: string;
78
80
  store: import("./message-store.js").MessageStore;
79
81
  client: GoClient;
80
82
  /** Executor session key for a chat (e.g. lma-<bot>-<chatId>). */
@@ -1,5 +1,14 @@
1
1
  /** Independent LMA supervision. MessageStore owns objective, criteria and completion. */
2
2
  import { goSnapshotBlocked } from "./go-runtime-gate.js";
3
+ /** Logical (profile-free) part of a session key. */
4
+ const withoutAgentPrefix = (key) => key.replace(/^agent:[^:]+:/, '');
5
+ /** Physical session key for a logical executor key under this bot's profile. */
6
+ const physicalKey = (key, profile) => {
7
+ if (/^agent:[^:]+:/.test(key))
8
+ return key;
9
+ const agent = String(profile || "main").trim() || "main";
10
+ return `agent:${agent}:${key}`;
11
+ };
3
12
  const CHECK_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_GO_CHECK_DELAY_MS || 20000);
4
13
  const CHECKER_TIMEOUT_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_GO_CHECKER_TIMEOUT_MS || 10 * 60 * 1000);
5
14
  const MAX_CONTINUES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_GO_MAX_CONTINUES || 10);
@@ -120,7 +129,7 @@ export class GoController {
120
129
  const executor = sessionKey === job.executorSessionKey;
121
130
  const result = executor ? await client.collectGoExecutorRun({ runId: receipt.runId, sessionKey, sessionId: expectedSessionId, timeoutMs: CHECKER_TIMEOUT_MS }) : null;
122
131
  if (result && (result.runId !== receipt.runId || result.sessionId !== expectedSessionId ||
123
- (result.sessionKey !== sessionKey && result.sessionKey !== `agent:main:${sessionKey}`)))
132
+ (result.sessionKey !== sessionKey && result.sessionKey !== physicalKey(sessionKey, this.d.profile))))
124
133
  throw new Error("Executor result identity mismatch");
125
134
  const text = result ? result.text : await client.collectGoRun({ runId: receipt.runId, sessionKey, sessionId: expectedSessionId, timeoutMs: CHECKER_TIMEOUT_MS });
126
135
  this.d.store.recordGoRunResult(jobId, actionKey, { state: "complete", runId: receipt.runId, text, ...(result ? { executorResult: result } : {}) });
@@ -204,7 +213,10 @@ export class GoController {
204
213
  await this.d.reply(messageId, `⚠️ 当前已有活动 Go #${existing.id}:\n目标:${existing.goal.slice(0, 200)}\n如需更换目标,请先 /go stop。`);
205
214
  return;
206
215
  }
207
- const executorKey = this.d.executorSessionKeyFor(chatId);
216
+ // The Go supervision path talks to the Gateway directly, so it uses the
217
+ // PHYSICAL key throughout (profile included) exactly like the executor
218
+ // session itself.
219
+ const executorKey = physicalKey(this.d.executorSessionKeyFor(chatId), this.d.profile);
208
220
  if (await this.safetyUnavailable(executorKey)) {
209
221
  await this.d.reply(messageId, "❌ Go 未启动:当前运行时尚未验证 Checker 工具隔离、精确子孙任务/投递门禁及条件发送。不能仅凭提示词安全开启自动监督。独立模式无需原生 Goal;普通聊天不受影响。");
210
222
  return;
@@ -230,7 +242,10 @@ export class GoController {
230
242
  || (typeof session.model === "string" && session.model
231
243
  ? (typeof session.modelProvider === "string" && session.modelProvider && !session.model.includes("/") ? `${session.modelProvider}/${session.model}` : session.model)
232
244
  : this.d.fallbackModel);
233
- const checkerKey = `${executorKey.replace(/^lma-/, "lma-go-")}-${crypto.randomUUID()}`;
245
+ // The checker session NAME stays profile-free (lma- -> lma-go-), exactly like
246
+ // every other logical key LMA stores. It is addressed under the bot's
247
+ // profile only when it is actually handed to the Gateway.
248
+ const checkerKey = physicalKey(`${withoutAgentPrefix(executorKey).replace(/^lma-/, "lma-go-")}-${crypto.randomUUID()}`, this.d.profile);
234
249
  try {
235
250
  await this.d.client.createSession({ key: checkerKey, model: resolvedModel, label: `Go Checker: ${this.d.botName} ${chatId.slice(-6)}` });
236
251
  }
@@ -349,7 +364,7 @@ export class GoController {
349
364
  if (saved?.state !== "complete") {
350
365
  const result = await this.d.client.readGoExecutorRunResult?.({ ...send, runId: receipt.runId });
351
366
  if (!result || result.runId !== receipt.runId || result.sessionId !== job.executorSessionId ||
352
- ![job.executorSessionKey, `agent:main:${job.executorSessionKey}`].includes(result.sessionKey)) {
367
+ ![job.executorSessionKey, physicalKey(job.executorSessionKey, this.d.profile)].includes(result.sessionKey)) {
353
368
  this.d.store.recordGoRunResult(job.id, send.actionKey, { state: "unknown", runId: receipt.runId });
354
369
  continue;
355
370
  }
@@ -510,16 +525,17 @@ export class GoController {
510
525
  // ---------------- check cycle ----------------
511
526
  async executorBusy(chatId, job) {
512
527
  try {
513
- if (await this.safetyUnavailable(job.executorSessionKey))
528
+ const executorPhysicalKey = job.executorSessionKey;
529
+ if (await this.safetyUnavailable(executorPhysicalKey))
514
530
  return "运行时安全能力未验证";
515
- const info = await (this.d.client.getGoSessionInfo?.bind(this.d.client) ?? this.d.client.getSessionInfo.bind(this.d.client))(job.executorSessionKey);
531
+ const info = await (this.d.client.getGoSessionInfo?.bind(this.d.client) ?? this.d.client.getSessionInfo.bind(this.d.client))(executorPhysicalKey);
516
532
  if (info?.session?.sessionId !== job.executorSessionId || info?.session?.archived)
517
533
  return "执行会话代际已改变或归档";
518
534
  const identity = this.d.store.getGoNativeIdentity(job.id);
519
535
  if (!identity)
520
536
  return "Missing independent run binding";
521
- const snapshot = await this.d.client.readGoRuntimeSnapshot(job.executorSessionKey, job.executorSessionId);
522
- const blocked = goSnapshotBlocked(snapshot, { sessionKey: job.executorSessionKey.startsWith("agent:") ? job.executorSessionKey : `agent:main:${job.executorSessionKey}`, sessionId: job.executorSessionId,
537
+ const snapshot = await this.d.client.readGoRuntimeSnapshot(executorPhysicalKey, job.executorSessionId);
538
+ const blocked = goSnapshotBlocked(snapshot, { sessionKey: executorPhysicalKey, sessionId: job.executorSessionId,
523
539
  goalId: identity.nativeGoalId, goalRevision: identity.goalRevision, executorRunIds: identity.executorRunIds });
524
540
  if (blocked) {
525
541
  if (blocked === "identity/version changed")
@@ -0,0 +1,45 @@
1
+ /** Display-only references. Never read by admission, routing, reactions or replay. */
2
+ export type InputHintKind = 'received' | 'queued' | 'submitting' | 'append_queued' | 'accepted' | 'processing' | 'waiting' | 'paused' | 'unconfirmed' | 'delivery_pending' | 'partial' | 'completed' | 'no_reply' | 'replied' | 'failed' | 'blocked';
3
+ export type InputHint = {
4
+ rowId?: number;
5
+ messageId?: string;
6
+ kind: InputHintKind;
7
+ detail?: string;
8
+ at: number;
9
+ preview?: string;
10
+ previewSource?: 'visible';
11
+ };
12
+ export type InputStatusContext = {
13
+ botName?: string;
14
+ appId?: string;
15
+ chatId?: string;
16
+ stopEpoch?: number;
17
+ items: InputHint[];
18
+ note?: InputHint;
19
+ };
20
+ export type InputHintChange = {
21
+ kind: InputHintKind;
22
+ detail?: string;
23
+ ids?: number[];
24
+ at: number;
25
+ };
26
+ export type InputDisplayLimits = {
27
+ preview: number;
28
+ members: number;
29
+ activity: number;
30
+ };
31
+ export declare const LIVE_CARD_BYTES = 30000;
32
+ export declare const INPUT_DISPLAY_LIMITS: InputDisplayLimits[];
33
+ export declare function hintLabel(kind: InputHintKind, en: boolean): string;
34
+ export declare function hintDetail(kind: InputHintKind, en: boolean): string;
35
+ export declare function displayClip(value: string, max: number): string;
36
+ /** Explicit visible human ingress: escaping/clipping only. Never infer media or
37
+ * generated prompts from text that merely looks like a system annotation. */
38
+ export declare function visibleInputPreview(content: string, max: number, en: boolean): string;
39
+ /** Legacy stored/hydrated body fallback; explicit visible ingress never uses it. */
40
+ export declare function inputPreview(content: string, max: number, en: boolean): string;
41
+ /** Use the existing markdown component as inert text, not as a user-authored
42
+ * card/mention/link language. Entity introducers/platform tags are neutralized
43
+ * before markdown punctuation is escaped. No media or URL is fetched. */
44
+ export declare function inertInputText(value: string): string;
45
+ export declare function selectInputHints(items: InputHint[], max: number): InputHint[];
@@ -0,0 +1,67 @@
1
+ export const LIVE_CARD_BYTES = 30_000;
2
+ export const INPUT_DISPLAY_LIMITS = [
3
+ { preview: 160, members: 12, activity: 180 }, { preview: 96, members: 10, activity: 120 },
4
+ { preview: 48, members: 6, activity: 80 }, { preview: 20, members: 2, activity: 24 },
5
+ ];
6
+ const labels = {
7
+ received: ['📥 已收件', '📥 Received'], queued: ['⏳ 本地排队', '⏳ Queued'], submitting: ['📨 提交待确认', '📨 Submission pending'],
8
+ append_queued: ['⏳ 追加排队', '⏳ Append queued'], accepted: ['✅ 已接收', '✅ Accepted'], processing: ['⚙️ 处理中', '⚙️ Processing'],
9
+ waiting: ['⏳ 等待后续', '⏳ Waiting'], paused: ['⏸️ 等待暂停', '⏸️ Wait paused'], unconfirmed: ['❔ 结果待确认', '❔ Result unconfirmed'],
10
+ delivery_pending: ['📨 投递待确认', '📨 Delivery pending'], partial: ['⚠️ 部分确认', '⚠️ Partial'], completed: ['✅ 本轮结束', '✅ Round ended'],
11
+ no_reply: ['💤 无正文', '💤 No content'], replied: ['💬 关联已回复', '💬 Linked reply'], failed: ['⚠️ 执行中断', '⚠️ Interrupted'], blocked: ['⛔ 尚未提交', '⛔ Not submitted'],
12
+ };
13
+ const details = {
14
+ received: ['尚未确认 Gateway 接收', 'Gateway acceptance unconfirmed'], queued: ['等待提交确认', 'Waiting for admission'],
15
+ submitting: ['已开始提交;Gateway 接收尚未确认', 'Submission started; Gateway acceptance unconfirmed'],
16
+ append_queued: ['实际接收方、提交及处理结果未确认', 'Actual receiver, commitment and result unconfirmed'],
17
+ accepted: ['Gateway 已接收;等待执行活动', 'Gateway accepted; waiting for activity'],
18
+ partial: ['root 结果仅覆盖普通输入;追加输入提交及处理仍未确认,不重放', 'Root result covers ordinary inputs only; appended commitment/result remain unconfirmed, no replay'],
19
+ waiting: ['保留后续结果观察;不自动重发输入', 'Continuing result observation; no input replay'],
20
+ paused: ['仅暂停等待;未因此停止任务或重发输入', 'Waiting paused only; no task stop or input replay'],
21
+ unconfirmed: ['最终结果尚未确认', 'Final result unconfirmed'], delivery_pending: ['平台投递回执尚未确认;不重复发送', 'Platform receipt unconfirmed; no duplicate send'],
22
+ completed: ['本轮结果仅覆盖实际普通输入', 'This round covers only its ordinary inputs'],
23
+ no_reply: ['本轮未产生用户可见正文', 'No user-visible content this round'],
24
+ replied: ['关联轮次回复已送达;不据此确认追加输入提交', 'Linked run reply delivered; input commitment remains separate'],
25
+ failed: ['本轮执行中断;不据此归因用户停止', 'Execution interrupted; no user-stop attribution'],
26
+ blocked: ['保留原输入;不迁移或自动重发', 'Input retained; no migration or automatic replay'],
27
+ };
28
+ export function hintLabel(kind, en) { return `【${(labels[kind] || labels.unconfirmed)[en ? 1 : 0]}】`; }
29
+ export function hintDetail(kind, en) { return details[kind]?.[en ? 1 : 0] || ''; }
30
+ export function displayClip(value, max) {
31
+ // Code-point safe, including malformed isolated surrogates; no split emoji
32
+ // surrogate, JSON fragment or half a UTF-8 sequence is emitted.
33
+ const points = Array.from(String(value), ch => { const n = ch.codePointAt(0); return n >= 0xd800 && n <= 0xdfff ? '�' : ch; });
34
+ return points.length > max ? points.slice(0, Math.max(0, max - 1)).join('') + '…' : points.join('');
35
+ }
36
+ /** Explicit visible human ingress: escaping/clipping only. Never infer media or
37
+ * generated prompts from text that merely looks like a system annotation. */
38
+ export function visibleInputPreview(content, max, en) {
39
+ const text = String(content || '').replace(/\s+/g, ' ').trim();
40
+ return displayClip(text || (en ? '[Message body unavailable]' : '[消息正文暂不可用]'), max);
41
+ }
42
+ /** Legacy stored/hydrated body fallback; explicit visible ingress never uses it. */
43
+ export function inputPreview(content, max, en) {
44
+ const filename = (value) => value.trim().split(/[\\/]/).at(-1) || (en ? 'file' : '文件');
45
+ const raw = String(content || '').trim();
46
+ if (raw === '请回复上面最近一条用户消息。')
47
+ return en ? '[Message body unavailable]' : '[消息正文暂不可用]';
48
+ const text = raw
49
+ .replace(/\n\n\[飞书文档已由 LMA 用机器人权限读取并转换为 Markdown 附件,OpenClaw 可直接读取附件内容。\][\s\S]*$/, '')
50
+ .replace(/\[(?:Image|Audio|Sticker)(?::[^\]]*)?\]/gi, match => /^\[Image/i.test(match) ? (en ? '[Image]' : '[图片]') : /^\[Audio/i.test(match) ? (en ? '[Audio]' : '[语音]') : (en ? '[Sticker]' : '[贴纸]'))
51
+ .replace(/\[File:\s*([^\]]*)\]/gi, (_all, value) => `[${en ? 'File' : '文件'}: ${filename(value.split(/\s+->\s+|\s+- download failed/)[0])}]`)
52
+ .replace(/\[(?:FeishuDoc|Attachment):[^\]]*\]/gi, en ? '[Document]' : '[文档]')
53
+ .replace(/\s+/g, ' ').trim();
54
+ return displayClip(text || (en ? '[Message body unavailable]' : '[消息正文暂不可用]'), max);
55
+ }
56
+ /** Use the existing markdown component as inert text, not as a user-authored
57
+ * card/mention/link language. Entity introducers/platform tags are neutralized
58
+ * before markdown punctuation is escaped. No media or URL is fetched. */
59
+ export function inertInputText(value) {
60
+ return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/[\\`*_{}\[\]()#+!|~]/g, '\\$&');
61
+ }
62
+ export function selectInputHints(items, max) {
63
+ if (items.length <= max)
64
+ return items;
65
+ const first = Math.max(1, Math.floor(max / 3));
66
+ return [...items.slice(0, first), ...items.slice(-(max - first))];
67
+ }
@@ -1,3 +1,4 @@
1
+ import type { InputHintChange, InputHintKind, InputStatusContext } from "./input-status-display.js";
1
2
  import type { ProgressEvent } from "./openclaw-client";
2
3
  /** One activity line shown in the card content area. */
3
4
  export type LiveStatusLine = {
@@ -5,9 +6,13 @@ export type LiveStatusLine = {
5
6
  text: string;
6
7
  /** Relative time (seconds since run start) when this line was produced. */
7
8
  at: number;
9
+ /** Delivery hint text is rendered through exact input references, not activity markdown. */
10
+ inputHint?: boolean;
8
11
  };
9
12
  /** Structured snapshot the renderer turns into a Feishu interactive card. */
10
13
  export type LiveStatusView = {
14
+ /** UI-only source references; bodies are resolved at the actual card boundary. */
15
+ inputStatus?: InputStatusContext;
11
16
  /** Card title, e.g. "Claude 正在执行" / "✅ Claude 已完成". */
12
17
  title: string;
13
18
  /** Activity lines (oldest first); when finished this holds a summary instead. */
@@ -35,6 +40,7 @@ export type LiveStatusFinalMeta = {
35
40
  thinkingLevel?: string;
36
41
  locale: "zh" | "en";
37
42
  noReply?: boolean;
43
+ inputStatus?: InputStatusContext;
38
44
  };
39
45
  /** Optional external presentation owner (e.g. the durable outbox). A local
40
46
  * execution outcome is NOT a platform delivery receipt. Once handed off, late
@@ -61,6 +67,8 @@ export declare class LiveStatusEffectRejectedError extends Error {
61
67
  export declare class LiveStatusEffectSuppressedError extends Error {
62
68
  }
63
69
  export type LiveStatusCallbacks = {
70
+ /** UI-only update/read; cannot grant admission, progress or input completion. */
71
+ inputStatus?: (change?: InputHintChange) => InputStatusContext | undefined;
64
72
  /** Create the status card; returns the new message id. */
65
73
  create: (view: LiveStatusView, effect: LiveStatusEffect<string | undefined>) => Promise<string | undefined>;
66
74
  /** Patch the existing status card. Reject with LiveStatusEffectRejectedError
@@ -73,6 +81,7 @@ export type LiveStatusCallbacks = {
73
81
  unconfirmedInputs?: (outcome: "done" | "failed") => {
74
82
  title: string;
75
83
  detail: string;
84
+ ordinaryIds?: number[];
76
85
  } | undefined;
77
86
  };
78
87
  export type LiveStatusOptions = {
@@ -116,6 +125,7 @@ export declare class LiveStatusController {
116
125
  private generation;
117
126
  /** Frozen/terminal presentation wins over every older progress snapshot. */
118
127
  private pinnedView?;
128
+ private pinnedInputGuard?;
119
129
  private terminalOwner?;
120
130
  private pendingEdit?;
121
131
  private editPump?;
@@ -136,17 +146,19 @@ export declare class LiveStatusController {
136
146
  private toolCallCount;
137
147
  /** True when finished via noReply(): show a "no content" summary. */
138
148
  private noReplyResult;
149
+ private fallbackInputHint?;
139
150
  constructor(callbacks: LiveStatusCallbacks, opts: LiveStatusOptions);
140
151
  /** Receipt is durable locally, NOT Gateway admission. */
141
- received(detail: string): void;
152
+ received(detail: string, ids?: number[], kind?: InputHintKind): void;
142
153
  /** A proven never-attempted ordinary recovery may reuse its queued card.
143
154
  * Merely observing an expected root never calls this method. */
144
155
  resumeQueued(): void;
145
156
  /** Enqueue/local receipt remains waiting, not actual-owner/model evidence.
146
157
  * Keep one latest pinned view while allowing another input to coalesce. */
147
- showInputWaiting(detail: string, title?: string): Promise<void>;
158
+ showInputWaiting(detail: string, title?: string, ids?: number[], kind?: InputHintKind): Promise<void>;
148
159
  /** Only the exact ACK owner calls this; admission is not model execution. */
149
160
  admitted(successor?: boolean): Promise<void>;
161
+ submitting(): void;
150
162
  get id(): string | undefined;
151
163
  start(initialDetail?: string): void;
152
164
  setMetadata(model?: string, thinkingLevel?: string): void;
@@ -156,7 +168,7 @@ export declare class LiveStatusController {
156
168
  showWaitingForResult(detail: string): Promise<void>;
157
169
  /** Release only local observation of a now-idle session, without claiming
158
170
  * the previous task succeeded or was aborted. Normal progress is unchanged. */
159
- pauseForUnconfirmedResult(detail: string, title?: string): Promise<void>;
171
+ pauseForUnconfirmedResult(detail: string, title?: string, kind?: InputHintKind): Promise<void>;
160
172
  /** Close only the display after the associated run's original-chat reply is
161
173
  * platform-confirmed. This deliberately does not claim inserted-input commit. */
162
174
  linkedReplyDelivered(deliveredAt: number, preserveReplyPayload?: boolean, retryZeroEffect?: boolean): Promise<void>;
@@ -181,6 +193,10 @@ export declare class LiveStatusController {
181
193
  */
182
194
  noReply(): Promise<void>;
183
195
  dispose(): void;
196
+ /** Refresh only the existing UI-owned card after recall. Never create a
197
+ * card, restart ticking, change phase or invoke a business terminal owner.
198
+ * Unknown effects remain fenced; a later known receipt retains this guard. */
199
+ redactRecalledInput(effectGuard: () => boolean): Promise<void>;
184
200
  private pinView;
185
201
  private finishView;
186
202
  private reconcilePinnedView;
@@ -194,6 +210,9 @@ export declare class LiveStatusController {
194
210
  private appendText;
195
211
  private normalizeText;
196
212
  private clip;
213
+ private terminalInputHint;
214
+ private updateInputHint;
215
+ private inputSnapshot;
197
216
  private pushLine;
198
217
  private stopTimers;
199
218
  private ensureCreatedNow;
@@ -47,6 +47,7 @@ export class LiveStatusController {
47
47
  generation = 0;
48
48
  /** Frozen/terminal presentation wins over every older progress snapshot. */
49
49
  pinnedView;
50
+ pinnedInputGuard;
50
51
  terminalOwner;
51
52
  pendingEdit;
52
53
  editPump;
@@ -67,6 +68,7 @@ export class LiveStatusController {
67
68
  toolCallCount = 0;
68
69
  /** True when finished via noReply(): show a "no content" summary. */
69
70
  noReplyResult = false;
71
+ fallbackInputHint;
70
72
  constructor(callbacks, opts) {
71
73
  this.callbacks = callbacks;
72
74
  this.opts = opts;
@@ -84,11 +86,13 @@ export class LiveStatusController {
84
86
  this.noReplyResult = restored.view.noReply;
85
87
  this.waitingForResult = restored.view.phase === "waiting";
86
88
  this.updatesFrozen = this.waitingForResult;
87
- if (restored.view.state === "replied") {
89
+ if (['done', 'failed', 'replied'].includes(restored.view.state)) {
88
90
  this.finalized = true;
89
91
  this.terminalOutcome = true;
90
92
  this.updatesFrozen = true;
91
93
  }
94
+ if (this.waitingForResult || this.terminalOutcome)
95
+ this.pinnedView = restored.view;
92
96
  }
93
97
  // Attempted/unknown create survives process loss as a no-create fence.
94
98
  if (restored.blockCreate && !this.messageId)
@@ -96,10 +100,11 @@ export class LiveStatusController {
96
100
  }
97
101
  }
98
102
  /** Receipt is durable locally, NOT Gateway admission. */
99
- received(detail) {
103
+ received(detail, ids, kind = "received") {
100
104
  if (this.finalized || this.disposed)
101
105
  return;
102
- this.pushLine("lifecycle", detail);
106
+ this.updateInputHint(kind, detail, ids);
107
+ this.pushLine("lifecycle", detail, true);
103
108
  this.callbacks.desired?.(this.buildView());
104
109
  void this.safeEdit(this.buildView());
105
110
  }
@@ -113,6 +118,7 @@ export class LiveStatusController {
113
118
  this.waitingForResult = false;
114
119
  this.waitingCreateAllowed = false;
115
120
  this.state = "queued";
121
+ this.updateInputHint("queued");
116
122
  this.generation++;
117
123
  this.pinnedView = undefined;
118
124
  this.pendingEdit = undefined;
@@ -126,13 +132,14 @@ export class LiveStatusController {
126
132
  }
127
133
  /** Enqueue/local receipt remains waiting, not actual-owner/model evidence.
128
134
  * Keep one latest pinned view while allowing another input to coalesce. */
129
- async showInputWaiting(detail, title) {
135
+ async showInputWaiting(detail, title, ids, kind = "waiting") {
130
136
  if (this.disposed || this.terminalOutcome)
131
137
  return;
132
138
  this.updatesFrozen = true;
133
139
  this.waitingForResult = true;
134
140
  this.waitingCreateAllowed = !this.messageId && !this.disabled;
135
- this.pushLine("lifecycle", detail);
141
+ this.updateInputHint(kind, detail, ids);
142
+ this.pushLine("lifecycle", detail, true);
136
143
  this.stopTimers();
137
144
  const view = this.buildView();
138
145
  if (title)
@@ -152,10 +159,18 @@ export class LiveStatusController {
152
159
  return;
153
160
  this.waitingForResult = false;
154
161
  this.state = "admitted";
155
- this.pushLine("lifecycle", this.opts.locale === "en" ? "Gateway accepted; waiting for activity" : "Gateway 已接收,等待执行活动");
162
+ this.updateInputHint("accepted");
163
+ this.pushLine("lifecycle", this.opts.locale === "en" ? "Gateway accepted; waiting for activity" : "Gateway 已接收,等待执行活动", true);
156
164
  this.callbacks.desired?.(this.buildView());
157
165
  await this.safeEdit(this.buildView());
158
166
  }
167
+ submitting() {
168
+ if (this.finalized || this.disposed)
169
+ return;
170
+ this.updateInputHint("submitting");
171
+ this.callbacks.desired?.(this.buildView());
172
+ void this.safeEdit(this.buildView());
173
+ }
159
174
  get id() { return this.messageId; }
160
175
  start(initialDetail) {
161
176
  if (this.createSuppressed) {
@@ -236,6 +251,7 @@ export class LiveStatusController {
236
251
  }
237
252
  this.waitingForResult = false;
238
253
  this.state = "running";
254
+ this.updateInputHint("processing");
239
255
  this.callbacks.desired?.(this.buildView());
240
256
  await this.ensureCreatedNow();
241
257
  await this.safeEdit(this.buildView());
@@ -247,16 +263,18 @@ export class LiveStatusController {
247
263
  return;
248
264
  this.updatesFrozen = true;
249
265
  this.waitingForResult = true;
250
- this.pushLine("lifecycle", detail);
266
+ this.updateInputHint("waiting", detail);
267
+ this.pushLine("lifecycle", detail, true);
251
268
  this.stopTimers();
252
269
  await this.finishView(this.buildView());
253
270
  }
254
271
  /** Release only local observation of a now-idle session, without claiming
255
272
  * the previous task succeeded or was aborted. Normal progress is unchanged. */
256
- async pauseForUnconfirmedResult(detail, title) {
273
+ async pauseForUnconfirmedResult(detail, title, kind = "unconfirmed") {
257
274
  if (this.finalized)
258
275
  return;
259
- this.pushLine("lifecycle", detail);
276
+ this.updateInputHint(kind, detail);
277
+ this.pushLine("lifecycle", detail, true);
260
278
  this.waitingForResult = true;
261
279
  this.finalized = true;
262
280
  this.stopTimers();
@@ -271,6 +289,7 @@ export class LiveStatusController {
271
289
  if (this.disposed || this.terminalOutcome && !safeRetry || !Number.isFinite(deliveredAt) || deliveredAt < this.startedAt)
272
290
  return;
273
291
  this.state = "replied";
292
+ this.updateInputHint("replied");
274
293
  this.waitingForResult = false;
275
294
  this.updatesFrozen = true;
276
295
  this.finalized = true;
@@ -294,9 +313,9 @@ export class LiveStatusController {
294
313
  async complete() {
295
314
  if (this.disposed || this.terminalOutcome)
296
315
  return;
297
- const pending = this.callbacks.unconfirmedInputs?.("done");
316
+ const pending = this.terminalInputHint("completed", "done");
298
317
  if (pending) {
299
- await this.showInputWaiting(pending.detail, pending.title);
318
+ await this.showInputWaiting(pending.detail, pending.title, [], pending.ordinaryIds?.length ? "partial" : "queued");
300
319
  return;
301
320
  }
302
321
  this.terminalOutcome = true;
@@ -317,9 +336,9 @@ export class LiveStatusController {
317
336
  async prepareTerminalDelivery(noReply = false, owner) {
318
337
  if (this.disposed || (this.terminalOutcome && this.state !== "done"))
319
338
  return undefined;
320
- const pending = this.callbacks.unconfirmedInputs?.("done");
339
+ const pending = this.terminalInputHint(noReply ? "no_reply" : "delivery_pending", "done");
321
340
  if (pending) {
322
- await this.showInputWaiting(pending.detail, pending.title);
341
+ await this.showInputWaiting(pending.detail, pending.title, [], pending.ordinaryIds?.length ? "partial" : "queued");
323
342
  return undefined;
324
343
  }
325
344
  if (owner) {
@@ -350,6 +369,7 @@ export class LiveStatusController {
350
369
  thinkingLevel: this.opts.thinkingLevel,
351
370
  locale: this.opts.locale === "en" ? "en" : "zh",
352
371
  noReply: this.noReplyResult,
372
+ inputStatus: this.inputSnapshot(),
353
373
  };
354
374
  }
355
375
  async prepareFinalDelivery(owner) {
@@ -358,9 +378,9 @@ export class LiveStatusController {
358
378
  async fail() {
359
379
  if (this.disposed || this.terminalOutcome)
360
380
  return;
361
- const pending = this.callbacks.unconfirmedInputs?.("failed");
381
+ const pending = this.terminalInputHint("failed", "failed");
362
382
  if (pending) {
363
- await this.showInputWaiting(pending.detail, pending.title);
383
+ await this.showInputWaiting(pending.detail, pending.title, [], pending.ordinaryIds?.length ? "partial" : "queued");
364
384
  return;
365
385
  }
366
386
  this.terminalOutcome = true;
@@ -377,9 +397,9 @@ export class LiveStatusController {
377
397
  async noReply() {
378
398
  if (this.disposed || this.terminalOutcome)
379
399
  return;
380
- const pending = this.callbacks.unconfirmedInputs?.("done");
400
+ const pending = this.terminalInputHint("no_reply", "done");
381
401
  if (pending) {
382
- await this.showInputWaiting(pending.detail, pending.title);
402
+ await this.showInputWaiting(pending.detail, pending.title, [], pending.ordinaryIds?.length ? "partial" : "queued");
383
403
  return;
384
404
  }
385
405
  this.terminalOutcome = true;
@@ -396,7 +416,26 @@ export class LiveStatusController {
396
416
  this.pendingEdit = undefined;
397
417
  this.stopTimers();
398
418
  }
399
- pinView(view) {
419
+ /** Refresh only the existing UI-owned card after recall. Never create a
420
+ * card, restart ticking, change phase or invoke a business terminal owner.
421
+ * Unknown effects remain fenced; a later known receipt retains this guard. */
422
+ async redactRecalledInput(effectGuard) {
423
+ if (this.disposed)
424
+ return;
425
+ this.lines = this.lines.filter(line => line.kind !== 'steer');
426
+ const base = this.pinnedView || this.buildView();
427
+ const view = { ...base, lines: base.lines.filter(line => line.kind !== 'steer'), inputStatus: this.inputSnapshot() };
428
+ if (this.terminalOwner) {
429
+ this.callbacks.desired?.(view);
430
+ return;
431
+ }
432
+ this.pinView(view, effectGuard);
433
+ if (!this.messageId || this.blockedEdits || this.unknownEdits.size || !effectGuard())
434
+ return;
435
+ await this.queueEdit(view, true, true, effectGuard);
436
+ }
437
+ pinView(view, effectGuard) {
438
+ this.pinnedInputGuard = effectGuard;
400
439
  this.generation++;
401
440
  this.callbacks.desired?.(view);
402
441
  this.pinnedView = view;
@@ -414,7 +453,7 @@ export class LiveStatusController {
414
453
  if (!this.pinnedView || this.disposed)
415
454
  return;
416
455
  if (!this.terminalOwner) {
417
- await this.queueEdit(this.pinnedView, true, force);
456
+ await this.queueEdit(this.pinnedView, true, force, this.pinnedInputGuard);
418
457
  return;
419
458
  }
420
459
  const meta = this.finalMeta();
@@ -452,7 +491,35 @@ export class LiveStatusController {
452
491
  const maxChars = this.opts.maxChars ?? DEFAULT_MAX_CHARS;
453
492
  return text.length > maxChars ? `${text.slice(0, Math.max(0, maxChars - 1))}\u2026` : text;
454
493
  }
455
- pushLine(kind, text) {
494
+ terminalInputHint(kind, outcome) {
495
+ const pending = this.callbacks.unconfirmedInputs?.(outcome);
496
+ // Existing custody decides membership FIRST. Presentation never invents
497
+ // an admitted run or upgrades an unconfirmed/appended input's outcome.
498
+ if (!pending)
499
+ this.updateInputHint(kind);
500
+ else if (pending.ordinaryIds?.length)
501
+ this.updateInputHint(kind, undefined, pending.ordinaryIds);
502
+ return pending;
503
+ }
504
+ updateInputHint(kind, detail, ids) {
505
+ const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
506
+ this.fallbackInputHint = { kind, detail, at };
507
+ try {
508
+ this.callbacks.inputStatus?.({ kind, detail, ids, at });
509
+ }
510
+ catch (err) {
511
+ this.callbacks.warn?.("input display checkpoint unavailable", err);
512
+ }
513
+ }
514
+ inputSnapshot() {
515
+ try {
516
+ return this.callbacks.inputStatus?.() || (this.fallbackInputHint ? { items: [this.fallbackInputHint] } : undefined);
517
+ }
518
+ catch {
519
+ return { items: [{ kind: "unconfirmed", at: 0 }] };
520
+ }
521
+ }
522
+ pushLine(kind, text, inputHint = false) {
456
523
  const clean = this.normalizeText(text);
457
524
  if (!clean)
458
525
  return;
@@ -462,7 +529,7 @@ export class LiveStatusController {
462
529
  if (last && last.kind === kind && last.text === clipped)
463
530
  return;
464
531
  const at = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
465
- this.lines.push({ kind, text: clipped, at });
532
+ this.lines.push({ kind, text: clipped, at, ...(inputHint ? { inputHint: true } : {}) });
466
533
  const limit = this.opts.historySize ?? DEFAULT_HISTORY;
467
534
  if (this.lines.length > limit)
468
535
  this.lines = this.lines.slice(this.lines.length - limit);
@@ -559,7 +626,7 @@ export class LiveStatusController {
559
626
  await this.queueEdit(view, false);
560
627
  }
561
628
  /** One flight + one latest pending snapshot, regardless of tick/event rate. */
562
- queueEdit(view, priority, force = false) {
629
+ queueEdit(view, priority, force = false, effectGuard) {
563
630
  this.callbacks.desired?.(view);
564
631
  if (this.blockedEdits)
565
632
  return Promise.resolve();
@@ -568,7 +635,7 @@ export class LiveStatusController {
568
635
  return Promise.resolve();
569
636
  if (!priority)
570
637
  this.generation++;
571
- this.pendingEdit = { view, generation: this.generation, priority, force, started: false, settled: false, accepted: false };
638
+ this.pendingEdit = { view, generation: this.generation, priority, force, effectGuard, started: false, settled: false, accepted: false };
572
639
  return this.flushEdits();
573
640
  }
574
641
  canRunPendingEdit() {
@@ -628,7 +695,7 @@ export class LiveStatusController {
628
695
  };
629
696
  const effect = {
630
697
  run: async (send) => {
631
- if (!current()) {
698
+ if (!current() || request.effectGuard && !request.effectGuard()) {
632
699
  request.settled = true;
633
700
  return;
634
701
  }
@@ -689,7 +756,7 @@ export class LiveStatusController {
689
756
  /** Dedupe key includes elapsed so the footer timer ticks even without new
690
757
  * activity, while identical content within the same second is still skipped. */
691
758
  signature(view) {
692
- return `${view.state}|${view.title}|${view.elapsed}|${view.lines.map((l) => `${l.kind}@${l.at}:${l.text}`).join("|")}`;
759
+ return `${view.state}|${view.title}|${view.elapsed}|${JSON.stringify(view.inputStatus || null)}|${view.lines.map((l) => `${l.kind}@${l.at}:${l.text}`).join("|")}`;
693
760
  }
694
761
  isNoReplyText(text) {
695
762
  return text.trim().toUpperCase() === "NO_REPLY";
@@ -741,6 +808,7 @@ export class LiveStatusController {
741
808
  return {
742
809
  title,
743
810
  lines,
811
+ inputStatus: this.inputSnapshot(),
744
812
  elapsed: this.formatElapsed(),
745
813
  model: this.opts.model,
746
814
  thinkingLevel: this.opts.thinkingLevel,