@sema-agent/client-core 0.11.15 → 0.11.17

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.
@@ -3,43 +3,10 @@
3
3
  * 本模块自持 AskUserQuestion 的 wire 词汇(SDK 0.0.44 删掉了 questions 资源导出),搬入后这套
4
4
  * 类型随包发布 —— web/桌面端的 HITL 覆盖层与 TUI 共用同一份 QuestionFrame/QuestionAnswer 形。
5
5
  */
6
- /**
7
- * src/sema/liveQuestionStore.ts the SIDE-CHANNEL for §4④ live-stream AskUserQuestion (the conversation-seam
8
- * twin of liveSessionStore.ts).
9
- *
10
- * WHY THIS EXISTS
11
- * ---------------
12
- * In TOC (two-process) the MODEL runs in the engine; its `AskUserQuestion` tool fires on core's
13
- * `RunnerDeps.onQuestion` seam and the service (question.ts, 1.61.0) emits a NAMED SSE frame
14
- * (`event: question` / `event: question_complete`) that INTERLEAVES on the same live token stream as the
15
- * AgentEvents. That frame is NOT an AgentEvent arm (it is a DISTINCT vocabulary, exactly like elicitation) —
16
- * so it must be DEMUXED out of the transcript-render pipeline and routed to an interactive DIALOG overlay
17
- * instead. This tiny, dependency-FREE module is that route: liveClient WRITES the demuxed frame here (on each
18
- * `question`/`question_complete`) and publishes its `client.questions.respond` binding; the UI overlay
19
- * (main.tsx / REPL) READS the frames and answers via `respondToQuestion`. Same shape as liveSessionStore /
20
- * rewindAnchorStore — the overlay stays testable (no @sema-ai/sdk graph) and seamQuery/normalizeToolNames are
21
- * untouched.
22
- *
23
- * 🔴 LIVE-ONLY + SAME-REPLICA (the `steer()`-class of HITL): the service parks the answer promise in-memory,
24
- * so a respond that lands after the TTL (5 min) / after answer / on another replica → 404. No durable resume
25
- * anchor — a mid-question disconnect just lets the run proceed with the headless default (the model never
26
- * hangs). The overlay must therefore treat `respondToQuestion` as best-effort (a 404 = "already released",
27
- * dismiss the dialog, never retry).
28
- *
29
- * 🔴 UNTRUSTED: the `questions` payload is model-authored (service secret-redacted, but still UNTRUSTED for
30
- * display). The overlay RENDERS it; it must NEVER be re-fed to a model. The answer the overlay sends back is
31
- * fenced by CORE (`selected ⊆ options`, `note` untrusted-fenced) — the overlay must echo the EXACT option
32
- * labels the user saw, never fabricate one.
33
- */
34
- /* ── LOCAL wire types (owned HERE, not @sema-ai/sdk) ─────────────────────────────────────────────
35
- * SDK 0.0.44 DELETED the `questions` resource (engine 1.163 parks the run `suspended` instead of
36
- * emitting `question` SSE frames — see liveHitlAskWire.ts), and with it the QuestionFrame /
37
- * QuestionAnswer / AskQuestion exports. The shell still speaks this vocabulary INTERNALLY (synthetic
38
- * frames from liveHitlAskWire / planReviewWire + the legacy 1.61-era SSE demux in liveClient), so the
39
- * types now live in THIS dependency-free module — the store this whole seam already routes through.
40
- * Shapes are the ones the shell actually produces/consumes (askq-wire-probe + overlay usage), byte-
41
- * compatible with the old wire payload `{type,questionId,questions?,outcome?}`. */
42
- let frameHandler = null;
6
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from './sessionSlot.js';
7
+ // W1(design/161):frameHandler 槽位改 sessionKey 注册表;零参 API = DEFAULT_SESSION_KEY
8
+ // 兼容层(cli 装配不动)。respondFn / localResponders 不在 W1 五槽位切口内,维持原样。
9
+ const frameHandlerByKey = createSessionSlot();
43
10
  let respondFn = null;
44
11
  /** SHELL-LOCAL question responders (plan-review approval cards etc. — synthetic frames whose id does
45
12
  * NOT exist engine-side, so POST /v1/questions/:id/respond would 404). Registered per-questionId by
@@ -59,6 +26,11 @@ export function registerLocalQuestionResponder(id, fn) {
59
26
  * break the live stream drain (the run must keep flowing; a lost frame just headless-defaults the ask).
60
27
  */
61
28
  export function publishQuestionFrame(frame) {
29
+ publishQuestionFrameFor(DEFAULT_SESSION_KEY, frame);
30
+ }
31
+ /** W1 keyed variant: publish to ONE session's overlay (multi-session hosts demux by their own key). */
32
+ export function publishQuestionFrameFor(sessionKey, frame) {
33
+ const frameHandler = frameHandlerByKey.get(sessionKey);
62
34
  if (!frameHandler)
63
35
  return;
64
36
  try {
@@ -71,14 +43,23 @@ export function publishQuestionFrame(frame) {
71
43
  /** Whether a UI overlay is currently subscribed (liveHitlAskWire gates on this: print/non-REPL mode has no
72
44
  * dialog to park on, so the suspended terminal must fall through honestly instead of hanging the stream). */
73
45
  export function hasQuestionOverlay() {
74
- return frameHandler !== null;
46
+ return hasQuestionOverlayFor(DEFAULT_SESSION_KEY);
47
+ }
48
+ /** W1 keyed variant. */
49
+ export function hasQuestionOverlayFor(sessionKey) {
50
+ return frameHandlerByKey.get(sessionKey) !== undefined;
75
51
  }
76
52
  /** Subscribe the UI overlay to live question frames. Returns an unsubscribe. Last writer wins (one overlay). */
77
53
  export function onQuestionFrame(handler) {
78
- frameHandler = handler;
54
+ return onQuestionFrameFor(DEFAULT_SESSION_KEY, handler);
55
+ }
56
+ /** W1 keyed variant: one overlay PER sessionKey (last writer wins within a key; keys never clobber
57
+ * each other — the r1 E2 dual-session hazard this registry exists to close). */
58
+ export function onQuestionFrameFor(sessionKey, handler) {
59
+ frameHandlerByKey.set(sessionKey, handler);
79
60
  return () => {
80
- if (frameHandler === handler)
81
- frameHandler = null;
61
+ if (frameHandlerByKey.get(sessionKey) === handler)
62
+ frameHandlerByKey.set(sessionKey, undefined);
82
63
  };
83
64
  }
84
65
  /** Publish the live client's `questions.respond` binding (called from createLiveConversationClient). `null`
@@ -104,6 +85,6 @@ export function respondToQuestion(id, answer, opts) {
104
85
  }
105
86
  /** Test-only reset. */
106
87
  export function _resetLiveQuestionStore() {
107
- frameHandler = null;
88
+ frameHandlerByKey.clear();
108
89
  respondFn = null;
109
90
  }
@@ -182,9 +182,11 @@ type BgTaskStatusProbe = (taskId: string) => Promise<{
182
182
  terminal: boolean;
183
183
  status: string;
184
184
  } | null>;
185
+ /** 委派 prompt 取件口(行消费端建行/详情页兜底用)。未登记 ⇒ undefined(诚实缺席)。 */
186
+ export declare function outstandingBgTaskPrompt(taskId: string): string | undefined;
185
187
  export declare function installBgTaskStatusProbe(probe: BgTaskStatusProbe): void;
186
188
  /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
187
- export declare function registerOutstandingBgTask(taskId: string, description: string): void;
189
+ export declare function registerOutstandingBgTask(taskId: string, description: string, prompt?: string): void;
188
190
  export declare function isOwnWorkflowRun(runId: string): boolean;
189
191
  /** 本壳亲手启动过的 workflow run 列表(Set 插入序 = 启动序;/workflows 命令的目标 run 选择用,
190
192
  * cmd-workflows.tsx——fleet source 无行可选时的兜底 id 源)。 */
@@ -388,12 +388,31 @@ export function installWorkflowStatusProbe(probe) {
388
388
  }
389
389
  const outstandingBgTasks = new Map();
390
390
  let bgStatusProbe = null;
391
+ /**
392
+ * R4(clay 五报 07-31)委派 prompt 台账:`async_launched` 回执带全 {task_id, description, prompt}
393
+ * 三件,但此前 prompt 只进消息 payload(ctrl+o expand 那条腿),任务行/详情页(↓ manage)取不到,
394
+ * 只能绕道等引擎 running 期不发的 transcriptId 去转录读面捞 ⇒ Prompt 节恒空。
395
+ * 这里按行 id 记一份(与 fleet 行 rowIdTail 同 keyspace),行消费端建行时取用。
396
+ * 与 outstandingBgTasks 分开存:后者随通知摘除,prompt 在行的整个生命周期都要可取。
397
+ * process-lifetime 有界:bg 子代数量 = 人手派发量级,不设逐出。
398
+ */
399
+ const bgTaskPrompts = new Map();
400
+ /** 委派 prompt 取件口(行消费端建行/详情页兜底用)。未登记 ⇒ undefined(诚实缺席)。 */
401
+ export function outstandingBgTaskPrompt(taskId) {
402
+ return bgTaskPrompts.get(taskId);
403
+ }
391
404
  export function installBgTaskStatusProbe(probe) {
392
405
  bgStatusProbe = probe;
393
406
  }
394
407
  /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
395
- export function registerOutstandingBgTask(taskId, description) {
396
- if (!taskId || notifiedRunIds.has(taskId) || outstandingBgTasks.has(taskId))
408
+ export function registerOutstandingBgTask(taskId, description, prompt) {
409
+ if (!taskId)
410
+ return;
411
+ // prompt 台账先记(与 watcher 登记的幂等早退解耦:重复回执/已通知任务的 prompt 仍要可取)。
412
+ if (typeof prompt === 'string' && prompt.length > 0 && !bgTaskPrompts.has(taskId)) {
413
+ bgTaskPrompts.set(taskId, prompt);
414
+ }
415
+ if (notifiedRunIds.has(taskId) || outstandingBgTasks.has(taskId))
397
416
  return;
398
417
  outstandingBgTasks.set(taskId, { registeredAt: Date.now(), description });
399
418
  ensureWatchTimer();
@@ -81,6 +81,8 @@ export interface TaskRequestInput {
81
81
  attachments?: Record<string, unknown>;
82
82
  /** 预上传附件句柄(`uploadAttachment` 回执 id)。空数组 = 没带附件 ⇒ 整键不 stamp。 */
83
83
  attachmentIds?: readonly string[];
84
+ /** 排除工具名单(SDK 真 wire 键;desktop 座位面在发)。空数组 = 不排除 ⇒ 整键不 stamp。 */
85
+ excludeTools?: readonly string[];
84
86
  finalVerification?: boolean;
85
87
  limits?: Record<string, unknown>;
86
88
  interactiveTools?: false;
@@ -34,6 +34,7 @@ export const REQUEST_FIELD_MATRIX = [
34
34
  // B4 从壳的三个构造器逐行读出来的,那时这条车道只有壳一个消费者。`attachmentIds` 是**反过来**
35
35
  // 的第一条:它今天只有 web/desktop 在发,壳反而没有这个入口。别把本节读成「壳独有」。
36
36
  { field: 'attachmentIds', lanes: ['interactive'], live: true, why: '附件**字节通道**(uploadAttachment 回执 id;server ≥1.289 绑定会话并把文件物化进 run 的 attachments/)。有上传入口的端才有 id 可引用:web/desktop 有,壳走 images / 本地文件路径,`-p` 车道连 uploadAttachment 都没有 ⇒ print 缺席是**没有来源**,不是漏。🔴 与上面的 `attachments` 同名不同物(那是 turn 边界的配置键),两条永远不许合并' },
37
+ { field: 'excludeTools', lanes: ['interactive'], live: true, why: 'design/161 W3(2026-07-30):真 wire 键(SDK types `excludeTools`)且早在 seatContract `START_SESSION_OPTION_KEYS` 里,却是 desktop 手写 taskReq 的第 9 键、矩阵外——而 stamp 门对未登记键**静默丢弃**,真实危险形=「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)。今天只有 desktop 在发;壳的工具面走 roster/interactiveTools 另一条路,print 无排除入口' },
37
38
  { field: 'settings.ultracode', lanes: ['interactive'], live: true, why: 'design/111:sticky `/effort ultracode` 拨盘 + 当轮关键词嗅探,两个来源都只在交互面存在', gap: true },
38
39
  { field: 'systemPrompt', lanes: ['interactive'], live: false, why: 'CC QueryParams.systemPrompt;print 腿的 params 没有这一位' },
39
40
  { field: 'reasoningEffort', lanes: ['interactive'], live: false, why: '`/effort` 拨盘存在 AppState,print 无 AppState', gap: true },
@@ -115,6 +116,10 @@ export function buildTaskRequest(input, lane) {
115
116
  ...(on('attachmentIds', input.attachmentIds) && (input.attachmentIds?.length ?? 0) > 0
116
117
  ? { attachmentIds: [...(input.attachmentIds ?? [])] }
117
118
  : {}),
119
+ // 同款空数组语义:「不排除任何工具」不 stamp(缺席与空排除在 wire 上等价,发小的那个)。
120
+ ...(on('excludeTools', input.excludeTools) && (input.excludeTools?.length ?? 0) > 0
121
+ ? { excludeTools: [...(input.excludeTools ?? [])] }
122
+ : {}),
118
123
  ...(on('clientContext', input.clientContext) ? { clientContext: input.clientContext } : {}),
119
124
  ...(on('scratchpadDir', input.scratchpadDir) ? { scratchpadDir: input.scratchpadDir } : {}),
120
125
  ...(on('finalVerification', input.finalVerification)
@@ -0,0 +1,26 @@
1
+ /**
2
+ * sessionSlot.ts — W1(design/161):包内「模块级可变单槽位」的 per-session 注册表最小工厂。
3
+ *
4
+ * 背景(design/161 复审 r1 E2):包内五个模块级单槽位(host.ts `host` / askGateWire
5
+ * `hostSurface` / toolApprovalWire `cardPort` / liveQuestionStore `frameHandler` /
6
+ * engineWireTarget `installed`)与多会话宿主(desktop)正面冲突 —— 两个并行会话互相顶盖
7
+ * (A 会话的审批卡弹到 B 的面上 / B 的 responder 覆盖 A 的)。W1 把这五个槽位改成
8
+ * `Map<sessionKey, T>` 注册表:
9
+ * · **零参旧 API = DEFAULT_SESSION_KEY 兼容层**(cli 的 module-load 装配一行不动,
10
+ * default 键路径与改前单变量行为逐字节等价);
11
+ * · 带 key 的 `*For(sessionKey, …)` 变体给多会话宿主用,每会话一键,互不顶盖。
12
+ * 契约锚 = `agentSession/contract.ts` 的 `AgentSessionConfig.sessionKey`。
13
+ *
14
+ * 🔴 本文件只管「存取」,不带任何业务语义 —— miss 计数、fail-soft 文言、还原/合并语义都留在
15
+ * 各槽位模块里(五个槽位形状略异,强行归一就是过度设计)。
16
+ */
17
+ /** 零参旧 API 的兼容键(cli 单会话装配走它;多会话宿主每会话一键,别用这个值当会话 id)。 */
18
+ export declare const DEFAULT_SESSION_KEY = "__default__";
19
+ /** per-session 槽位存取面。`get` 未装过 = undefined(各槽位模块自己 `?? null` 归一)。 */
20
+ export interface SessionSlot<T> {
21
+ get(key: string): T | undefined;
22
+ /** `undefined` = 卸下该键(与「从未装过」同态 —— 槽位模块的 prev-还原语义靠它闭合)。 */
23
+ set(key: string, value: T | undefined): void;
24
+ clear(): void;
25
+ }
26
+ export declare function createSessionSlot<T>(): SessionSlot<T>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * sessionSlot.ts — W1(design/161):包内「模块级可变单槽位」的 per-session 注册表最小工厂。
3
+ *
4
+ * 背景(design/161 复审 r1 E2):包内五个模块级单槽位(host.ts `host` / askGateWire
5
+ * `hostSurface` / toolApprovalWire `cardPort` / liveQuestionStore `frameHandler` /
6
+ * engineWireTarget `installed`)与多会话宿主(desktop)正面冲突 —— 两个并行会话互相顶盖
7
+ * (A 会话的审批卡弹到 B 的面上 / B 的 responder 覆盖 A 的)。W1 把这五个槽位改成
8
+ * `Map<sessionKey, T>` 注册表:
9
+ * · **零参旧 API = DEFAULT_SESSION_KEY 兼容层**(cli 的 module-load 装配一行不动,
10
+ * default 键路径与改前单变量行为逐字节等价);
11
+ * · 带 key 的 `*For(sessionKey, …)` 变体给多会话宿主用,每会话一键,互不顶盖。
12
+ * 契约锚 = `agentSession/contract.ts` 的 `AgentSessionConfig.sessionKey`。
13
+ *
14
+ * 🔴 本文件只管「存取」,不带任何业务语义 —— miss 计数、fail-soft 文言、还原/合并语义都留在
15
+ * 各槽位模块里(五个槽位形状略异,强行归一就是过度设计)。
16
+ */
17
+ /** 零参旧 API 的兼容键(cli 单会话装配走它;多会话宿主每会话一键,别用这个值当会话 id)。 */
18
+ export const DEFAULT_SESSION_KEY = '__default__';
19
+ export function createSessionSlot() {
20
+ const values = new Map();
21
+ return {
22
+ get: (key) => values.get(key),
23
+ set: (key, value) => {
24
+ if (value === undefined)
25
+ values.delete(key);
26
+ else
27
+ values.set(key, value);
28
+ },
29
+ clear: () => values.clear(),
30
+ };
31
+ }
@@ -75,6 +75,13 @@ export const STRUCTURED_DETAIL_TYPES = new Set([
75
75
  'fork',
76
76
  'enter-plan-mode',
77
77
  'exit-plan-mode',
78
+ // core 2.7.0 跟车四型(engine-vocab 等值门抓获,[dep-bump-follow-on] 计数钉族):本表只判
79
+ // 「structured 在场」,switch 认不得的照旧走 text 回落 —— 加型=让正则反解对新卡退位,不加型
80
+ // =把引擎的结构化当裸对象。
81
+ 'file_unchanged',
82
+ 'worktree',
83
+ 'monitor-start',
84
+ 'path_not_in_root',
78
85
  ]);
79
86
  /** structured 在场判别:顶层 `type` ∈ 白名单 ⇒ 返回该 type,否则 undefined(= 不在场)。 */
80
87
  export function structuredDetailType(structured) {
@@ -610,7 +617,7 @@ modelText) {
610
617
  if (agentId === undefined || typeof s.description !== 'string' || typeof s.prompt !== 'string') {
611
618
  return null;
612
619
  }
613
- registerOutstandingBgTask(agentId, s.description);
620
+ registerOutstandingBgTask(agentId, s.description, s.prompt);
614
621
  return {
615
622
  toolUseResult: {
616
623
  isAsync: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.11.15",
3
+ "version": "0.11.17",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",