@sema-agent/server 7.76.2 → 7.77.0

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.
@@ -7,7 +7,9 @@ export type SubagentTailFrame = Record<string, unknown> & {
7
7
  type: string;
8
8
  };
9
9
  /** core forward 事件 → wire 投影(sync 主流五分支同源;不认识的帧类型=null 不发——tail 面只承诺
10
- * content 六类,新 core 帧类型先经这里显式收编再出 wire)。 */
10
+ * content 六类,新 core 帧类型先经这里显式收编再出 wire)。
11
+ * ⚠️ **本函数是无状态逐帧投影**:`reasoning_end`(S-310 的推理权威段)不在这里 —— 它要按句柄攒段,
12
+ * 住在 {@link SubagentTailBus.onForwardFrame}(publish 侧是唯一知道句柄的地方)。 */
11
13
  export declare function projectTailFrame(e: Record<string, unknown> & {
12
14
  type?: string;
13
15
  }): SubagentTailFrame | null;
@@ -50,9 +52,33 @@ export declare class SubagentTailBus {
50
52
  * 不需要"先有内容帧"才能学到"没有内容帧"。
51
53
  */
52
54
  private readonly modeByHandle;
55
+ /**
56
+ * 句柄 → 该句柄这条 tail 流上**在攒的推理段**(S-310)。
57
+ *
58
+ * 键必须是句柄,理由与 {@link modeByHandle} 同源再加一条硬的:两个并发子代的 `reasoning_delta` 在
59
+ * **同一个** forward sink 上交错到达,一只累积器会把两份思考拼成一段、再盖上其中一个的身份键。
60
+ *
61
+ * 生命期 = **有人在听的那段时间**:攒段住在 `hasSubscribers` 快门之内(tail 是 live-only 面,没人听
62
+ * 就不投影也不攒),最后一个订阅者走掉或句柄终态({@link forgetHandle})时整条删掉 ⇒ 本表恒被
63
+ * 订阅者数量所界,不需要第二道容量闸。
64
+ */
65
+ private readonly segByHandle;
53
66
  /** 三条 run 腿的 forward 回调里逐帧调(在 `hasSubscribers` 快门**之前**——没人在听时也要学,
54
67
  * 否则晚开的 tail 永远读不到供给形)。同值重复写是 no-op(热路径只付一次 Map.get)。 */
55
68
  noteHandleContentMode(bgAgentId: string, mode: TailContentMode): void;
69
+ /**
70
+ * 🔴 **三条 run 腿的 forward 回调唯一入口**(S-310 收口)——登记供给形 → 段界收段 → 逐帧投影 → publish。
71
+ *
72
+ * 此前这四步以**同一段六行代码**抄在三条腿上(sync 流腿、verify-resume 腿、resume-stream 腿),
73
+ * 差别为零;S-310 要给 tail 面补 `reasoning_end` 时,那就是第四、第五、第六份拷贝。收成一扇门之后
74
+ * 腿上只剩「句柄在不在」这一句判断,三腿不可能再分家(`test/subagent-tail-stream.test.ts` G18-G20)。
75
+ *
76
+ * 顺序即语义:权威段**先于**触发它的结构帧上 wire(消费端「整段替换已攒 delta」的替换点不与卡片交错),
77
+ * 与账本腿「文本行先于结构行」逐字同构。
78
+ */
79
+ onForwardFrame(bgAgentId: string, mode: TailContentMode, e: Record<string, unknown> & {
80
+ type?: string;
81
+ }): void;
56
82
  /**
57
83
  * 句柄终态时**撤销**登记(三条腿的 `onTaskNotification` 观察者调,与 `task_settled` 帧同处)。
58
84
  *
@@ -69,7 +95,7 @@ export declare class SubagentTailBus {
69
95
  * 的答案。core 今天把终态通知排在最后一条 forward 之后(fleet-bus 同注),复活又是人手动作,这条窗
70
96
  * 实际上不可达;真要关死需要给句柄加墓碑态,代价大于一位诊断标签的收益,故不做。
71
97
  */
72
- forgetHandleContentMode(bgAgentId: string): void;
98
+ forgetHandle(bgAgentId: string): void;
73
99
  /** 路由读(按 tail 的 target 句柄)。缺席 = 不知道,路由出 `"unknown"`。 */
74
100
  handleContentMode(bgAgentId: string): TailContentMode | undefined;
75
101
  /** 两条 run 腿的 fwdInternals 调用点:帧已投影(projectTailFrame),null 不进来。 */
@@ -1,5 +1,6 @@
1
1
  import { redactSecrets } from "../trace/redact.js";
2
2
  import { taskProgressEventData, toolStartEventData, toolEndEventData, textEndEventData } from "../trace/project.js";
3
+ import { createReasoningSegments } from "../trace/model-segment.js";
3
4
  const TAIL_QUEUE_CAP = 512;
4
5
  export function projectTailFrame(e) {
5
6
  const t = e.type;
@@ -41,12 +42,31 @@ const HOST_MODE_CAP = 4096;
41
42
  export class SubagentTailBus {
42
43
  subs = new Map();
43
44
  modeByHandle = new Map();
45
+ segByHandle = new Map();
44
46
  noteHandleContentMode(bgAgentId, mode) {
45
47
  if (this.modeByHandle.get(bgAgentId) === mode)
46
48
  return;
47
49
  putBounded(this.modeByHandle, bgAgentId, mode);
48
50
  }
49
- forgetHandleContentMode(bgAgentId) {
51
+ onForwardFrame(bgAgentId, mode, e) {
52
+ this.noteHandleContentMode(bgAgentId, mode);
53
+ if (!this.hasSubscribers(bgAgentId))
54
+ return;
55
+ let segs = this.segByHandle.get(bgAgentId);
56
+ if (segs === undefined) {
57
+ segs = createReasoningSegments();
58
+ this.segByHandle.set(bgAgentId, segs);
59
+ }
60
+ for (const end of segs.onEvent(e))
61
+ this.publish(bgAgentId, end);
62
+ const f = projectTailFrame(e);
63
+ if (f)
64
+ this.publish(bgAgentId, f);
65
+ }
66
+ forgetHandle(bgAgentId) {
67
+ for (const end of this.segByHandle.get(bgAgentId)?.cut() ?? [])
68
+ this.publish(bgAgentId, end);
69
+ this.segByHandle.delete(bgAgentId);
50
70
  this.modeByHandle.delete(bgAgentId);
51
71
  }
52
72
  handleContentMode(bgAgentId) {
@@ -84,8 +104,10 @@ export class SubagentTailBus {
84
104
  sub.closed = true;
85
105
  sub.wake?.();
86
106
  set.delete(sub);
87
- if (set.size === 0 && this.subs.get(bgAgentId) === set)
107
+ if (set.size === 0 && this.subs.get(bgAgentId) === set) {
88
108
  this.subs.delete(bgAgentId);
109
+ this.segByHandle.delete(bgAgentId);
110
+ }
89
111
  };
90
112
  const it = {
91
113
  [Symbol.asyncIterator]() {
@@ -44,6 +44,25 @@ export declare function principalTokenPresentedButUnverified(req: IncomingMessag
44
44
  * 调用链上的**证明三件套**,与本门(身份面)是两道门。
45
45
  */
46
46
  export declare function sendPrincipalRefusal(res: ServerResponse, req: IncomingMessage, config: ServiceConfig): void;
47
+ /**
48
+ * 🔴 **「这个调用方读得到这一行 run 吗」的唯一谓词**(S-297 抽取;此前只活在 `server.ts runOwnerOk` 的
49
+ * 函数体里,而 `runOwnerOk` 把**判断**与**404 怎么发**焊在一起 —— 于是任何需要同一条判断但不想 404 的
50
+ * 读面(如 `/mcp` 面板的 `lastLegMcp`:非属主就不投这个键、面板照常 200)只能手抄一份,而手抄那份必然
51
+ * 漏掉下面这条窄互认臂:`explicitOperatorOk` 顶注已经为同族的病写过「SINGLE mint point,不要再造兄弟」)。
52
+ *
53
+ * 三条放行臂,别的一律不放:
54
+ * · `owner === null` —— 无属主的行(dev / 存量),本来就是开放的;
55
+ * · `principal === owner` —— 自己的行。⚠️ `principal` 必须是**验过**的身份(`gatedPrincipal`),
56
+ * 不是自述头 —— `owner` 是创建时从验过的 principal 落库的,拿自述头来比就等于谁都能读别人的 run;
57
+ * · [ref] C8 **窄互认**:非 `requirePrincipal` 部署上那批 `anon:shell-live` 哨兵行 + 无身份的调用方
58
+ * (cli 停发哨兵头后的存量会话,否则「能续跑但看不见」)。`gatedPrincipal` 的回值是 `string | undefined`
59
+ * ⇒ 原文那半个 `principal === null` 比较结构上不可达,抽取时一并去掉(语义逐字不变)。
60
+ *
61
+ * **不含 operator 臂**(刻意,[ref]⑥ 台账):跨属主 operator 在 poll/events/cancel 三读面上今天就是 404,
62
+ * 放宽它是一次独立裁定。需要 operator 臂的读面在**自己的调用点**显式并上 {@link explicitOperatorOk} ——
63
+ * 那样「谁多给了 operator 一条路」在代码上看得见,而不是被偷偷塞进共享谓词里。
64
+ */
65
+ export declare function runOwnerReadable(principal: string | undefined, owner: string | null, config: ServiceConfig): boolean;
47
66
  /** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
48
67
  * "true-for-all" is an unrestricted-write exec door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
49
68
  * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
@@ -65,6 +65,13 @@ export function sendPrincipalRefusal(res, req, config) {
65
65
  }
66
66
  sendError(res, 401, "auth.principal_required", `missing principal header '${config.principalHeader}'`);
67
67
  }
68
+ export function runOwnerReadable(principal, owner, config) {
69
+ if (owner === null)
70
+ return true;
71
+ if (principal === owner)
72
+ return true;
73
+ return !config.requirePrincipal && owner === "anon:shell-live" && principal === undefined;
74
+ }
68
75
  export function explicitOperatorOk(principal, operatorPrincipals) {
69
76
  return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
70
77
  }
@@ -3,14 +3,16 @@ import { a2aForScenario, mcpForScenario } from "../../config-center/facade.js";
3
3
  import { isUuidV7, isUuidShape, legacyUnownedSessionOk } from "../../security.js";
4
4
  import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
6
+ import { recordFailOpen } from "../../observability/fail-open.js";
6
7
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
7
8
  import { sendJson, sendError, sseHeaders } from "../send.js";
8
- import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
9
+ import { gatedPrincipal, explicitOperatorOk, runOwnerReadable, sendPrincipalRefusal } from "../principal-gate.js";
9
10
  const MCP_STATUS_TTL_MS = 5_000;
10
11
  const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
11
12
  const A2A_STATUS_TTL_MS = 5_000;
12
13
  const A2A_MATERIALIZE_TIMEOUT_MS = 10_000;
13
14
  const MEMORY_STATUS_TTL_MS = 5_000;
15
+ const LAST_LEG_LEDGER_TIMEOUT_MS = 2_000;
14
16
  const A2A_PANEL_MAX_TOOL_NAMES = 200;
15
17
  export function createSessionsLocal() {
16
18
  return {
@@ -21,6 +23,44 @@ export function createSessionsLocal() {
21
23
  memoryStatusCache: new Map(),
22
24
  };
23
25
  }
26
+ async function lastLegMcpFor(deps, sessionId, principal) {
27
+ let timer;
28
+ try {
29
+ return await Promise.race([
30
+ readLastLegMcp(deps, sessionId, principal),
31
+ new Promise((r) => { timer = setTimeout(() => { recordFailOpen("server.mcp-panel.last-leg-unreadable", `ledger read exceeded ${LAST_LEG_LEDGER_TIMEOUT_MS}ms`); r(undefined); }, LAST_LEG_LEDGER_TIMEOUT_MS); }),
32
+ ]);
33
+ }
34
+ catch (e) {
35
+ recordFailOpen("server.mcp-panel.last-leg-unreadable", e instanceof Error ? e.message : String(e ?? ""));
36
+ return undefined;
37
+ }
38
+ finally {
39
+ if (timer)
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+ async function readLastLegMcp(deps, sessionId, principal) {
44
+ const runStore = deps.runStore;
45
+ if (!runStore || sessionId === null)
46
+ return undefined;
47
+ const latest = await runStore.latestRunForSession(sessionId);
48
+ if (latest === undefined)
49
+ return undefined;
50
+ if (!runOwnerReadable(principal, latest.owner, deps.config) && !explicitOperatorOk(principal, deps.config.operatorPrincipals))
51
+ return undefined;
52
+ const events = await runStore.getEvents(latest.taskId, 0);
53
+ for (let i = events.length - 1; i >= 0; i--) {
54
+ const ev = events[i];
55
+ if (ev.type !== "wiring_manifest")
56
+ continue;
57
+ const mcp = ev.data?.mcp;
58
+ if (!Array.isArray(mcp))
59
+ return undefined;
60
+ return { runId: latest.taskId, at: ev.ts, mcp };
61
+ }
62
+ return undefined;
63
+ }
24
64
  export async function handleSessions(req, res, url, ctx) {
25
65
  const miss = { fell: false };
26
66
  await handleSessionsBody(req, res, url, ctx, miss);
@@ -71,17 +111,18 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
71
111
  {
72
112
  const mm = /^\/v1\/sessions\/([^/]+)\/mcp$/.exec(url);
73
113
  if (mm && req.method === "GET") {
74
- if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
114
+ const principal = gatedPrincipal(req, deps.config);
115
+ if (deps.config.requirePrincipal && !principal) {
75
116
  sendPrincipalRefusal(res, req, deps.config);
76
117
  return;
77
118
  }
119
+ const lastLegMcp = await lastLegMcpFor(deps, safeDecode(mm[1]), principal);
78
120
  const specs = mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario);
121
+ const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
79
122
  if (!specs || specs.length === 0) {
80
- sendJson(res, 200, { servers: [], asOf: new Date().toISOString() });
123
+ sendJson(res, 200, { servers: [], asOf: new Date().toISOString(), ...(lastLegMcp === undefined ? {} : { lastLegMcp }) });
81
124
  return;
82
125
  }
83
- const principal = gatedPrincipal(req, deps.config);
84
- const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
85
126
  const cached = mcpStatusCache.get(cacheKey);
86
127
  if (!cached || Date.now() - cached.at > MCP_STATUS_TTL_MS) {
87
128
  if (mcpStatusCache.size > 1024)
@@ -114,7 +155,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
114
155
  })();
115
156
  mcpStatusCache.set(cacheKey, { at: Date.now(), panel });
116
157
  }
117
- sendJson(res, 200, await mcpStatusCache.get(cacheKey).panel);
158
+ sendJson(res, 200, { ...(await mcpStatusCache.get(cacheKey).panel), ...(lastLegMcp === undefined ? {} : { lastLegMcp }) });
118
159
  return;
119
160
  }
120
161
  }
@@ -4,7 +4,8 @@ import { graftParkToolCallId, markChildrenStoppedByUserOnAbort, resumeAtHttpStat
4
4
  import { stripResumeToken } from "../../terminal.js";
5
5
  import { withPrincipal } from "../../observability/principal-context.js";
6
6
  import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../../fleet/fleet-bus.js";
7
- import { defaultSubagentTailBus, projectTailFrame } from "../../fleet/subagent-tail-bus.js";
7
+ import { defaultSubagentTailBus } from "../../fleet/subagent-tail-bus.js";
8
+ import { createReasoningSegments, reasoningStreamOfTask } from "../../trace/model-segment.js";
8
9
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../../orchestration/workflow-completion-inbox.js";
9
10
  import { createLedgerSink } from "../../trace/ledger-sink.js";
10
11
  import { registerEngineNoticeLeg } from "../../trace/engine-notice-wire.js";
@@ -313,23 +314,21 @@ async function handleTasksBody(req, res, url, ctx, miss) {
313
314
  const streamBody = async () => {
314
315
  await withPrincipal(principal, async () => {
315
316
  const tailContentMode = prepared.spec.forwardSubagentEvents === true ? "on" : "progress_only";
317
+ const childReasoning = createReasoningSegments();
318
+ const topReasoning = createReasoningSegments();
316
319
  const fwdInternals = {
317
320
  onForwardEvent: (e) => {
318
321
  fleetPub?.onForwardEvent(e);
319
322
  ledgerSink?.onForwardEvent(e);
320
323
  {
321
324
  const bg = e.bgAgentId;
322
- if (bg !== undefined) {
323
- defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
324
- if (defaultSubagentTailBus.hasSubscribers(bg)) {
325
- const f = projectTailFrame(e);
326
- if (f)
327
- defaultSubagentTailBus.publish(bg, f);
328
- }
329
- }
325
+ if (bg !== undefined)
326
+ defaultSubagentTailBus.onForwardFrame(bg, tailContentMode, e);
330
327
  }
331
328
  if (res.writableEnded)
332
329
  return;
330
+ for (const end of childReasoning.onEvent(e))
331
+ sseData(res, end);
333
332
  const t = e.type;
334
333
  if (t === "task_progress") {
335
334
  sseData(res, { type: "task_progress", ...taskProgressEventData(e) });
@@ -359,7 +358,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
359
358
  onTaskNotification: (n, opts) => {
360
359
  if (n.task_type === "workflow")
361
360
  return;
362
- defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
361
+ if (!res.writableEnded)
362
+ for (const end of childReasoning.cut(reasoningStreamOfTask(n.task_id, n.sessionId)))
363
+ sseData(res, end);
364
+ defaultSubagentTailBus.forgetHandle(n.task_id);
363
365
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
364
366
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
365
367
  }
@@ -393,6 +395,8 @@ async function handleTasksBody(req, res, url, ctx, miss) {
393
395
  for await (const ev of liveStream) {
394
396
  if (closed)
395
397
  break;
398
+ for (const end of ev.type === "done" ? [...childReasoning.cut(), ...topReasoning.cut()] : topReasoning.onEvent(ev))
399
+ sseData(res, end);
396
400
  if (ev.type === "message_committed" && ev.role === "user")
397
401
  userMsgEntryId = ev.entryId;
398
402
  const cancelVerbAtDone = ev.type === "done" && durableTaskId !== undefined && cancelledViaVerb.has(durableTaskId);
@@ -26,7 +26,7 @@ import { looksLikeJwt } from "@sema-agent/settings-schema/api/auth-bridge";
26
26
  import {} from "../orchestration/workflow-agent-steer.js";
27
27
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
28
28
  import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTerminalNotification } from "../fleet/fleet-bus.js";
29
- import { defaultSubagentTailBus, projectTailFrame } from "../fleet/subagent-tail-bus.js";
29
+ import { defaultSubagentTailBus } from "../fleet/subagent-tail-bus.js";
30
30
  import { APPROVAL_GATE_KINDS, createApprovalCardEmitter, isApprovalGateKind, resolveApprovalLeg, resolveStreamApprovalGate } from "../tool-approval.js";
31
31
  import { buildCheckpointAnchorHash } from "../approval-ask-audit-store.js";
32
32
  import { TERMINAL_PARK_GATE_KINDS } from "../plugins/checkpoint-store-sql.js";
@@ -85,7 +85,7 @@ export { cascadeConfig };
85
85
  import { handleImages, createImagesLocal, coarseStatusForState, errorCodeForExit } from "./routes/images.js";
86
86
  export { coarseStatusForState, errorCodeForExit };
87
87
  import { sendJson, sendError, httpErrorCode, msg, isSseResponse } from "./send.js";
88
- import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator, sendPrincipalRefusal } from "./principal-gate.js";
88
+ import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator, runOwnerReadable, sendPrincipalRefusal } from "./principal-gate.js";
89
89
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
90
90
  import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
91
91
  export { explicitOperatorOk, isOperator };
@@ -1453,14 +1453,8 @@ export function createHttpServer(rawDeps) {
1453
1453
  fleetPub?.onForwardEvent(e);
1454
1454
  {
1455
1455
  const bg = e.bgAgentId;
1456
- if (bg !== undefined) {
1457
- defaultSubagentTailBus.noteHandleContentMode(bg, verifyTailContentMode);
1458
- if (defaultSubagentTailBus.hasSubscribers(bg)) {
1459
- const f = projectTailFrame(e);
1460
- if (f)
1461
- defaultSubagentTailBus.publish(bg, f);
1462
- }
1463
- }
1456
+ if (bg !== undefined)
1457
+ defaultSubagentTailBus.onForwardFrame(bg, verifyTailContentMode, e);
1464
1458
  }
1465
1459
  if (rsV && taskId)
1466
1460
  verifySink.onForwardEvent(e);
@@ -1474,7 +1468,7 @@ export function createHttpServer(rawDeps) {
1474
1468
  onTaskNotification: (n, opts) => {
1475
1469
  if (n.task_type === "workflow")
1476
1470
  return;
1477
- defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
1471
+ defaultSubagentTailBus.forgetHandle(n.task_id);
1478
1472
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
1479
1473
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
1480
1474
  }
@@ -1578,14 +1572,8 @@ export function createHttpServer(rawDeps) {
1578
1572
  fleetPub?.onForwardEvent(e);
1579
1573
  {
1580
1574
  const bg = e.bgAgentId;
1581
- if (bg !== undefined) {
1582
- defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
1583
- if (defaultSubagentTailBus.hasSubscribers(bg)) {
1584
- const f = projectTailFrame(e);
1585
- if (f)
1586
- defaultSubagentTailBus.publish(bg, f);
1587
- }
1588
- }
1575
+ if (bg !== undefined)
1576
+ defaultSubagentTailBus.onForwardFrame(bg, tailContentMode, e);
1589
1577
  }
1590
1578
  if (!rs || !taskId)
1591
1579
  return;
@@ -1604,7 +1592,7 @@ export function createHttpServer(rawDeps) {
1604
1592
  onTaskNotification: (n, opts) => {
1605
1593
  if (n.task_type === "workflow")
1606
1594
  return;
1607
- defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
1595
+ defaultSubagentTailBus.forgetHandle(n.task_id);
1608
1596
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
1609
1597
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
1610
1598
  }
@@ -2334,8 +2322,7 @@ export function createHttpServer(rawDeps) {
2334
2322
  sendPrincipalRefusal(res, req, deps.config);
2335
2323
  return false;
2336
2324
  }
2337
- const legacyShellOwner = !deps.config.requirePrincipal && owner === "anon:shell-live" && (principal === undefined || principal === null);
2338
- if (!legacyShellOwner && owner !== null && principal !== owner) {
2325
+ if (!runOwnerReadable(principal, owner, deps.config)) {
2339
2326
  if (notFoundForm === "session")
2340
2327
  sendError(res, 404, "not_found.session", "session not found");
2341
2328
  else
@@ -13,6 +13,10 @@ export declare const FAIL_OPEN_TAGS: {
13
13
  readonly cls: "F";
14
14
  readonly note: "S-136 合并重扫确认项(`terminal.ts persistedPlaneOf`,持久 blob 的平面读面):盘上一条 `terminal` 因由形不合(`paused` 缺 gate / 词出闭集)让 core 的 `terminalProjection` 抛 ⇒ 本读面对这一行答**全格缺席**而不是整只 500。放行的最坏后果=一条坏行在列表/舰队读面上显示为无终局词;计数 + probe 带原句,便于按行回溯。";
15
15
  };
16
+ readonly "server.mcp-panel.last-leg-unreadable": {
17
+ readonly cls: "F";
18
+ readonly note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。";
19
+ };
16
20
  readonly "server.workflow-park-index.sync-failed": {
17
21
  readonly cls: "P-DEBT";
18
22
  readonly note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到,`/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`)——**方向是 fail-closed**(不误配、不伪造、不放行任何东西),丢的是「人能不能把这条批准送出去」这个能力。归 P 类的理由是它决定**车道选择**(缺行 ⇒ 选中 legacy 腿),归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车)。计数非零 = 有 park 因此决不掉,运维要按 detail 里的 runId 去查。";
@@ -4,6 +4,10 @@ export const FAIL_OPEN_TAGS = {
4
4
  cls: "F",
5
5
  note: "S-136 合并重扫确认项(`terminal.ts persistedPlaneOf`,持久 blob 的平面读面):盘上一条 `terminal` 因由形不合(`paused` 缺 gate / 词出闭集)让 core 的 `terminalProjection` 抛 ⇒ 本读面对这一行答**全格缺席**而不是整只 500。放行的最坏后果=一条坏行在列表/舰队读面上显示为无终局词;计数 + probe 带原句,便于按行回溯。",
6
6
  },
7
+ "server.mcp-panel.last-leg-unreadable": {
8
+ cls: "F",
9
+ note: "S-297(`http/routes/sessions.ts lastLegMcpFor`,`/mcp` 面板的可选键 `lastLegMcp`):按会话取最近 run 的索引读 / 那条 run 的账本读**抛错或超期** ⇒ 该键缺席,面板本体照常 200。放行的最坏后果=这一屏少一格「上一次真挂上了什么」(壳退回翻账本),不是门、不是权限、不是凭证 —— 而反方向(让附加键的读失败打掉整张面板)会给一口此前零 MCP 配置下根本不碰账本的**既有**读面新加一条挂掉的理由。缺席本就是本键 wire 上的一等状态(消费端必须会读),所以这条兜底不产生任何新的消费端形态;计数让「某台部署的账本一直读不出来」显形。",
10
+ },
7
11
  "server.workflow-park-index.sync-failed": {
8
12
  cls: "P-DEBT",
9
13
  note: "S-185 车CM(codex r1-F2 [high],验真后登记):workflow 出身 park 的 join 索引(`workflow_agent_session` / local 账本)在**一次成功的 run 写之后**同步失败 ⇒ 该次同步被吞,重试要等这条 run 的**下一次**写。而 park 恰好常常是这条 run 的**最后**一次写(park = run 终局 `failed`)⇒ 那条 park 可能永久 join 不到,`/decide` 结构性退回本批之前的行为(409 `conflict.resume_context_unavailable`)——**方向是 fail-closed**(不误配、不伪造、不放行任何东西),丢的是「人能不能把这条批准送出去」这个能力。归 P 类的理由是它决定**车道选择**(缺行 ⇒ 选中 legacy 腿),归 DEBT 的理由是真正的解要一条**按子代 checkpoint 键的耐久决议回执 + 重启期回填**(与 codex r1-F3 同一件欠账,另车)。计数非零 = 有 park 因此决不掉,运维要按 detail 里的 runId 去查。",
@@ -208,7 +208,7 @@ export async function decideParkedAgent(deps, req, match) {
208
208
  return "retry";
209
209
  }
210
210
  };
211
- defaultSubagentTailBus.forgetHandleContentMode(match.handle);
211
+ defaultSubagentTailBus.forgetHandle(match.handle);
212
212
  let receipt;
213
213
  try {
214
214
  receipt = await deps.reviveTool.execute(mintReviveArgs(row, deps.knownAgentTypes ?? new Set()), ctx);
@@ -1,6 +1,6 @@
1
1
  import { type UsageRow } from "../usage-analytics.js";
2
2
  import type { TaskStatus } from "@sema-agent/core";
3
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
3
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
4
4
  import type { RunStoreCheckpointProbe } from "./memory-run-store.js";
5
5
  import type { LedgerEventType } from "../trace/ledger-events.js";
6
6
  /**
@@ -127,6 +127,11 @@ export declare class FileRunStore {
127
127
  };
128
128
  limit: number;
129
129
  }): Promise<RunRecord[]>;
130
+ /**
131
+ * S-297 —— 这个会话**最近一条腿**的窄指针({@link SqlRunStore.latestRunForSession} 的孪生:同一个排序口径
132
+ * `createdAt DESC, taskId DESC`,与本店 {@link FileRunStore.listSessions} 挑 `rn=1` 那一行的比较器逐字同一份)。
133
+ */
134
+ latestRunForSession(sessionId: string): Promise<LatestRunRef | undefined>;
130
135
  /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
131
136
  listSessions(opts: {
132
137
  owner?: string;
@@ -443,6 +443,16 @@ export class FileRunStore {
443
443
  .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
444
444
  return rows.slice(0, opts.limit).map((r) => this.toRecord(r));
445
445
  }
446
+ async latestRunForSession(sessionId) {
447
+ let latest;
448
+ for (const r of this.runs.values()) {
449
+ if (r.sessionId !== sessionId)
450
+ continue;
451
+ if (latest === undefined || r.createdAt.getTime() > latest.createdAt.getTime() || (r.createdAt.getTime() === latest.createdAt.getTime() && r.taskId > latest.taskId))
452
+ latest = r;
453
+ }
454
+ return latest === undefined ? undefined : { taskId: latest.taskId, owner: latest.owner, createdAt: latest.createdAt.toISOString() };
455
+ }
446
456
  async listSessions(opts) {
447
457
  const bySession = new Map();
448
458
  for (const r of this.runs.values()) {
@@ -1,6 +1,6 @@
1
1
  import { type UsageRow } from "../usage-analytics.js";
2
2
  import type { TaskStatus } from "@sema-agent/core";
3
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
3
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
4
4
  import type { LedgerEventType } from "../trace/ledger-events.js";
5
5
  /**
6
6
  * [ref] P0 — the local lane's stand-in for the SQL twins' checkpoint-table JOIN. The file/memory run stores
@@ -76,6 +76,11 @@ export declare class MemoryRunStore {
76
76
  };
77
77
  limit: number;
78
78
  }): Promise<RunRecord[]>;
79
+ /**
80
+ * S-297 —— 这个会话**最近一条腿**的窄指针({@link SqlRunStore.latestRunForSession} 的孪生:同一个排序口径
81
+ * `createdAt DESC, taskId DESC`,与本店 {@link MemoryRunStore.listSessions} 挑 `rn=1` 那一行的比较器逐字同一份)。
82
+ */
83
+ latestRunForSession(sessionId: string): Promise<LatestRunRef | undefined>;
79
84
  /** DISTINCT sessions newest-first by last activity (the CC /resume picker): latest run's preview/status + first/last/count. */
80
85
  listSessions(opts: {
81
86
  owner?: string;
@@ -190,6 +190,16 @@ export class MemoryRunStore {
190
190
  .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime() || (a.taskId < b.taskId ? 1 : a.taskId > b.taskId ? -1 : 0));
191
191
  return rows.slice(0, opts.limit).map((r) => this.toRecord(r));
192
192
  }
193
+ async latestRunForSession(sessionId) {
194
+ let latest;
195
+ for (const r of this.runs.values()) {
196
+ if (r.sessionId !== sessionId)
197
+ continue;
198
+ if (latest === undefined || r.createdAt.getTime() > latest.createdAt.getTime() || (r.createdAt.getTime() === latest.createdAt.getTime() && r.taskId > latest.taskId))
199
+ latest = r;
200
+ }
201
+ return latest === undefined ? undefined : { taskId: latest.taskId, owner: latest.owner, createdAt: latest.createdAt.toISOString() };
202
+ }
193
203
  async listSessions(opts) {
194
204
  const bySession = new Map();
195
205
  for (const r of this.runs.values()) {
@@ -2,10 +2,10 @@ import type { TaskStatus } from "@sema-agent/core";
2
2
  import type { Pool as MySqlPool } from "mysql2/promise";
3
3
  import type { Pool as PgPool } from "pg";
4
4
  import { type UsageRow } from "../usage-analytics.js";
5
- import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from "./store-contracts.js";
5
+ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult, LatestRunRef } from "./store-contracts.js";
6
6
  import { type SqlDriver } from "./sql-driver.js";
7
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
8
- export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
8
+ export type { RunRecord, SessionSummary, RunEvent, LatestRunRef } from "./store-contracts.js";
9
9
  export declare class SqlRunStore {
10
10
  protected readonly db: SqlDriver;
11
11
  constructor(db: SqlDriver);
@@ -149,6 +149,17 @@ export declare class SqlRunStore {
149
149
  };
150
150
  limit: number;
151
151
  }): Promise<RunRecord[]>;
152
+ /**
153
+ * S-297 —— 这个会话**最近一条腿**的窄指针(`/mcp` 面板的 `lastLegMcp` 与任何「上一条 run 是谁」的读面共用)。
154
+ *
155
+ * 排序口径与 {@link listSessions} 的 `rn=1` 窗函数**逐字同一句**(`created_at DESC, task_id DESC`)——
156
+ * 两处答的是同一个问题「哪一条是最新的」,口径分叉会让列表面的 `lastRunId` 与本读口指向不同的 run。
157
+ * 走的也是同一条索引路径(`idx_task_run_session_status` 的 `session_id` 前缀),所以**不新加索引**:
158
+ * 会话内行数有界,前缀定位之后的排序只在这一小撮行上。
159
+ *
160
+ * 两方言只差占位符(动态子句为零,不像 listRuns/listSessions 那样需要各自的构造器臂)。
161
+ */
162
+ latestRunForSession(sessionId: string): Promise<LatestRunRef | undefined>;
152
163
  /** GET /v1/sessions (CC /resume picker): DISTINCT sessions newest-first by last activity, keyset-paginated on
153
164
  * (last_activity, session_id). Aggregates task_run by session_id — one window pass picks the latest run per
154
165
  * session (rn=1) for the preview/status and rolls up first/last/count. Owner-scoped when set (the per-user view).
@@ -252,6 +252,11 @@ export class SqlRunStore {
252
252
  const { rows } = await this.db.query(sql, params);
253
253
  return rows.map((r) => this.rowToRun(r));
254
254
  }
255
+ async latestRunForSession(sessionId) {
256
+ const { rows } = await this.db.query(this.q("SELECT task_id, owner, created_at FROM task_run WHERE session_id = ? ORDER BY created_at DESC, task_id DESC LIMIT 1", "SELECT task_id, owner, created_at FROM task_run WHERE session_id = $1 ORDER BY created_at DESC, task_id DESC LIMIT 1"), [sessionId]);
257
+ const r = rows[0];
258
+ return r === undefined ? undefined : { taskId: String(r.task_id), owner: r.owner ?? null, createdAt: iso(r.created_at) };
259
+ }
255
260
  async listSessions(opts) {
256
261
  if (this.db.dialect === "tidb") {
257
262
  const params = [];
@@ -244,6 +244,22 @@ export interface SessionSummary {
244
244
  * titled OR the lister face has no session_meta (the legacy task_run aggregate / memory twins — honest degrade). */
245
245
  title: string | null;
246
246
  }
247
+ /**
248
+ * S-297 —— 「这个会话**最近一条腿**是哪一条 run」的窄读面(`latestRunForSession` 的回值)。
249
+ *
250
+ * 刻意**不是** {@link RunRecord}:那一形携 `result` 整只 blob,而本读面的三个消费点(run 身份 / 属主门 /
251
+ * 排序口径的自证)一个都用不到它 —— 与 `listSessions` 只提标量 `lastTaskRunId` 而不拉 `result` 是同一条
252
+ * 判据([ref]:列表/指针面只拉标量,不把 blob 拖进按会话的高频读)。
253
+ *
254
+ * `owner` = 该 run 的**提交 principal**(可为 null = 无属主的存量行),消费点据它判「这条腿的读面该不该
255
+ * 投给当前调用方」;它与会话的 canonical owner 会分叉,判据与先例逐字见 `run-store-sql.ts deleteBySession`
256
+ * 的头注(F-A)。`createdAt` = 排序键(ISO),三孪生同一口径 `created_at DESC, task_id DESC`。
257
+ */
258
+ export interface LatestRunRef {
259
+ taskId: string;
260
+ owner: string | null;
261
+ createdAt: string;
262
+ }
247
263
  export interface RunEvent {
248
264
  seq: number;
249
265
  type: string;
package/dist/runs.js CHANGED
@@ -10,7 +10,7 @@ import { recordTurnActivity } from "./turn-activity.js";
10
10
  import { CANCELLED_CODE, mintCancelledResult } from "./run-cancel-context.js";
11
11
  import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
12
12
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
13
- import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
13
+ import { defaultSubagentTailBus } from "./fleet/subagent-tail-bus.js";
14
14
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
15
15
  import { recordFailOpen } from "./observability/fail-open.js";
16
16
  import { errorText as errText } from "./observability/err-text.js";
@@ -275,19 +275,13 @@ export async function runInBackground(runner, spec, runStore, taskId, metrics, p
275
275
  fleetPublisher?.onForwardEvent(e);
276
276
  sink.onForwardEvent(e);
277
277
  const bg = e.bgAgentId;
278
- if (bg !== undefined) {
279
- defaultSubagentTailBus.noteHandleContentMode(bg, tailContentMode);
280
- if (defaultSubagentTailBus.hasSubscribers(bg)) {
281
- const f = projectTailFrame(e);
282
- if (f)
283
- defaultSubagentTailBus.publish(bg, f);
284
- }
285
- }
278
+ if (bg !== undefined)
279
+ defaultSubagentTailBus.onForwardFrame(bg, tailContentMode, e);
286
280
  },
287
281
  onTaskNotification: (n, opts) => {
288
282
  if (n.task_type === "workflow")
289
283
  return;
290
- defaultSubagentTailBus.forgetHandleContentMode(n.task_id);
284
+ defaultSubagentTailBus.forgetHandle(n.task_id);
291
285
  if (defaultSubagentTailBus.hasSubscribers(n.task_id)) {
292
286
  defaultSubagentTailBus.publish(n.task_id, { type: "task_settled", taskId: n.task_id, status: n.status, ...(typeof n.seq === "number" ? { seq: n.seq } : {}), ...(n.summary ? { summary: redactSecrets(n.summary) } : {}) });
293
287
  }
@@ -2,6 +2,7 @@ import { terminalProjection } from "@sema-agent/core";
2
2
  import { modelTextEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, toolRosterDeltaEventData, humanInputEventData, turnEndEventData, contextUsageEventData } from "./project.js";
3
3
  import { NotifiedKeys, taskNotificationStreamKey } from "../orchestration/workflow-completion-inbox.js";
4
4
  import { isParkLedgerEventType } from "./ledger-events.js";
5
+ import { isModelSegmentBoundary } from "./model-segment.js";
5
6
  export function createSerialLedgerAppend(appendEvent, startSeq) {
6
7
  let seq = startSeq;
7
8
  let chain = Promise.resolve();
@@ -43,6 +44,8 @@ export function createLedgerSink(opts) {
43
44
  };
44
45
  const onEvent = async (ev) => {
45
46
  beat(ev.type);
47
+ if (isModelSegmentBoundary(ev.type))
48
+ await flush();
46
49
  switch (ev.type) {
47
50
  case "text_delta": {
48
51
  const td = ev;
@@ -55,15 +58,12 @@ export function createLedgerSink(opts) {
55
58
  reasoning += ev.delta;
56
59
  break;
57
60
  case "tool_start":
58
- await flush();
59
61
  await append("tool_start", toolStartEventData(ev));
60
62
  break;
61
63
  case "tool_end":
62
- await flush();
63
64
  await append("tool_end", toolEndEventData(ev));
64
65
  break;
65
66
  case "status":
66
- await flush();
67
67
  {
68
68
  const statusData = brainStatusEventData(ev);
69
69
  beat("status", statusData);
@@ -71,49 +71,39 @@ export function createLedgerSink(opts) {
71
71
  }
72
72
  break;
73
73
  case "context_usage":
74
- await flush();
75
74
  await append("context_usage", contextUsageEventData(ev));
76
75
  break;
77
76
  case "compacted":
78
- await flush();
79
77
  await append("compacted", compactedEventData(ev));
80
78
  break;
81
79
  case "diagnostics":
82
- await flush();
83
80
  await append("diagnostics", diagnosticsEventData(ev));
84
81
  break;
85
82
  case "steering_injected":
86
- await flush();
87
83
  await append("steering_injected", steeringInjectedEventData(ev));
88
84
  break;
89
85
  case "compaction_outcome":
90
- await flush();
91
86
  await append("compaction_outcome", compactionOutcomeEventData(ev));
92
87
  break;
93
88
  case "workspace_changed":
94
- await flush();
95
89
  await append("workspace_changed", workspaceChangedEventData(ev));
96
90
  break;
97
91
  case "wiring_manifest":
98
- await flush();
99
92
  await append("wiring_manifest", wiringManifestEventData(ev));
100
93
  break;
101
94
  case "tool_roster_delta": {
102
- await flush();
103
95
  const rosterDelta = toolRosterDeltaEventData(ev);
104
96
  if (rosterDelta !== undefined)
105
97
  await append("tool_roster_delta", rosterDelta);
106
98
  break;
107
99
  }
108
100
  case "human_input":
109
- await flush();
110
101
  await append("human_input", humanInputEventData(ev));
111
102
  break;
112
103
  case "task_progress":
113
104
  await append("task_progress", taskProgressEventData(ev));
114
105
  break;
115
106
  case "task_notification": {
116
- await flush();
117
107
  const tn = ev.notification;
118
108
  const tnKey = tn?.task_id ? taskNotificationStreamKey({ task_type: tn.task_type, task_id: tn.task_id, status: String(tn.status), seq: tn.seq }) : undefined;
119
109
  const tnPrior = tnKey !== undefined ? keys.get(tnKey) : undefined;
@@ -126,7 +116,6 @@ export function createLedgerSink(opts) {
126
116
  break;
127
117
  }
128
118
  case "turn_end": {
129
- await flush();
130
119
  const usage = ev.usage;
131
120
  await append("turn_end", turnEndEventData(ev));
132
121
  textEventId = undefined;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 本帧是不是模型文本的段界。**四条消费腿共用这一只**:账本投影 switch(`trace/ledger-sink.ts`)、
3
+ * 顶层 live 流与转发子代腿(`http/routes/tasks.ts`)、子代 tail 面(`fleet/subagent-tail-bus.ts`)。
4
+ * 未知/未来词一律 `false` —— 不是兜底,是上面三道钉保证「未知词不存在」之后剩下的诚实答案
5
+ * (第三方 Runner 塞进来的野帧不该切走宿主的权威段)。
6
+ */
7
+ export declare function isModelSegmentBoundary(type: string): boolean;
8
+ /** live 面的段末帧(`type` 即事件名;其余键由 `reasoningEndEventData` 白名单铸出)。 */
9
+ export type ReasoningEndFrame = Record<string, unknown> & {
10
+ type: "reasoning_end";
11
+ };
12
+ /** 登记册**读到的全部字段**,逐个写出来(不是 `Record<string, unknown>`):`TaskEvent` 与转发腿的原始帧
13
+ * 都结构性满足它,于是调用点一个宽断言都不必写;将来有人想读第七个键,得先在这里加一行。 */
14
+ export interface ReasoningStreamEvent {
15
+ type?: string;
16
+ delta?: unknown;
17
+ eventId?: unknown;
18
+ parentToolCallId?: unknown;
19
+ sourceTaskId?: unknown;
20
+ bgAgentId?: unknown;
21
+ }
22
+ /** 一条**流**的身份(不含 `eventId`:core 给每个内容事件铸各自的 eventId,拿它当流身份会把每块 delta
23
+ * 切成一段)。三键都缺席 = 宿主自己的流。 */
24
+ export interface ReasoningStreamIdent {
25
+ parentToolCallId?: string;
26
+ sourceTaskId?: string;
27
+ bgAgentId?: string;
28
+ }
29
+ /**
30
+ * 一条 live 腿上**按流身份分账**的 `reasoning` 段登记册。
31
+ *
32
+ * 🔴 **为什么是登记册而不是一只累积器**(codex 对抗复审 R1 两根 [high],亲跑复现后按类修):一条腿上
33
+ * 并发子代的 `reasoning_delta` 是**交错**到达的。一只累积器只有两种活法,两条都错:
34
+ * · 「换身份就收段」⇒ A 说一半、B 插一句、A 说另一半 ⇒ A 被切成两枚权威段,**跨块的凭据正好落在切口上**
35
+ * ——本帧存在的唯一理由当场失效(实测:两枚 `reasoning_end` 拼起来仍是明文密钥);
36
+ * · 「不换就不收」⇒ 两个子代的思考拼成一段,再盖上其中一个的身份键(串味 + 冤枉归属)。
37
+ * 分账之后规则反而**更少**:一条流的段,只被**它自己**的段界或**它自己**的终态收走;别人说什么都与它无关。
38
+ *
39
+ * 状态的界:每条流的条目在它的首块 delta 上建、在收段时删;腿终局 {@link ReasoningSegments.cut} 不带
40
+ * 匹配器 = 全收全删。子代终态那一次带匹配器的收段(转发腿的 `onTaskNotification`)是让**活着的子代数**
41
+ * 而不是「这条 run 一共派过多少子代」成为上界的那一手。
42
+ */
43
+ export interface ReasoningSegments {
44
+ /** 喂一帧 → 本帧收走的段末帧(0 或 1 枚,且**只可能是本帧自己那条流**的段)。返回数组只为让调用点
45
+ * 与 {@link cut} 同形(`for (const f of …) send(f)`),不是「一帧可能收多段」。 */
46
+ onEvent(ev: ReasoningStreamEvent): ReasoningEndFrame[];
47
+ /** 收段:`match` 缺省 = 本腿**全部**流(腿终局 / 句柄终态);给了就只收匹配的那几条(某个子代终态)。
48
+ * 与账本腿 `LedgerSink.onDone → flush()` 同位同义 —— 段界表里没有终局词,终局走这扇门。 */
49
+ cut(match?: (ident: ReasoningStreamIdent) => boolean): ReasoningEndFrame[];
50
+ }
51
+ /** 「这条流属于这些子代 id 之一」—— 子代终态收段的匹配器(`task_id` 与 `sessionId` 是同一个子代的两个
52
+ * id 域,取并集;`undefined` 成员自动忽略)。腿上共用这一只,免得各写一遍 id 域的并集。 */
53
+ export declare function reasoningStreamOfTask(...ids: ReadonlyArray<string | undefined>): (ident: ReasoningStreamIdent) => boolean;
54
+ export declare function createReasoningSegments(): ReasoningSegments;
55
+ //# sourceMappingURL=model-segment.d.ts.map
@@ -0,0 +1,58 @@
1
+ import { reasoningEndEventData } from "./project.js";
2
+ const BOUNDARY = new Set([
3
+ "tool_start", "tool_end", "status", "context_usage", "compacted", "diagnostics",
4
+ "steering_injected", "compaction_outcome", "workspace_changed", "wiring_manifest",
5
+ "tool_roster_delta", "human_input", "task_notification", "turn_end",
6
+ ]);
7
+ export function isModelSegmentBoundary(type) {
8
+ return BOUNDARY.has(type);
9
+ }
10
+ export function reasoningStreamOfTask(...ids) {
11
+ const set = new Set(ids.filter((v) => typeof v === "string" && v !== ""));
12
+ return (ident) => (ident.sourceTaskId !== undefined && set.has(ident.sourceTaskId)) || (ident.bgAgentId !== undefined && set.has(ident.bgAgentId));
13
+ }
14
+ function streamKeyOf(ident) {
15
+ return `${ident.parentToolCallId ?? ""}\u0000${ident.sourceTaskId ?? ""}\u0000${ident.bgAgentId ?? ""}`;
16
+ }
17
+ function identOf(ev) {
18
+ const s = (v) => (typeof v === "string" ? v : undefined);
19
+ return { parentToolCallId: s(ev.parentToolCallId), sourceTaskId: s(ev.sourceTaskId), bgAgentId: s(ev.bgAgentId) };
20
+ }
21
+ export function createReasoningSegments() {
22
+ const open = new Map();
23
+ const cutKey = (key) => {
24
+ const seg = open.get(key);
25
+ if (seg === undefined)
26
+ return [];
27
+ open.delete(key);
28
+ const data = reasoningEndEventData({ ...seg.ident, eventId: seg.eventId, content: seg.buf });
29
+ return data === null ? [] : [{ type: "reasoning_end", ...data }];
30
+ };
31
+ return {
32
+ onEvent: (ev) => {
33
+ if (ev.type === "reasoning_delta") {
34
+ if (typeof ev.delta !== "string" || ev.delta === "")
35
+ return [];
36
+ const ident = identOf(ev);
37
+ const key = streamKeyOf(ident);
38
+ const seg = open.get(key);
39
+ if (seg === undefined)
40
+ open.set(key, { ident, eventId: ev.eventId, buf: ev.delta });
41
+ else
42
+ seg.buf += ev.delta;
43
+ return [];
44
+ }
45
+ return isModelSegmentBoundary(ev.type ?? "") ? cutKey(streamKeyOf(identOf(ev))) : [];
46
+ },
47
+ cut: (match) => {
48
+ const out = [];
49
+ for (const [key, seg] of [...open]) {
50
+ if (match !== undefined && !match(seg.ident))
51
+ continue;
52
+ out.push(...cutKey(key));
53
+ }
54
+ return out;
55
+ },
56
+ };
57
+ }
58
+ //# sourceMappingURL=model-segment.js.map
@@ -194,6 +194,23 @@ export declare function screenedStoredEventData(type: string, data: unknown): un
194
194
  * 坏形(content 非串/空串)⇒ null(丢帧,不铸「权威空段」:core 契约明言空段不发本帧,消费端收到空段会清掉已攒 delta)。
195
195
  * 身份四键零字节变换(live 现状带全四键,消费端可能已依赖)。 */
196
196
  export declare function textEndEventData(ev: Record<string, unknown>): Record<string, unknown> | null;
197
+ /**
198
+ * `reasoning_end` 段末帧的**唯一**铸点(三条 live 腿共用:top stream / 转发子代腿 / 子代 tail 面)——
199
+ * `text_end` 的推理面孪生({@link textEndEventData}),形逐字同(共用 {@link segmentEndFields})。
200
+ *
201
+ * 🔴 **本帧由 server 自铸,core 不发**(`TaskEvent` 上只有 `reasoning_delta`)。`content` = 该段
202
+ * `reasoning_delta` 的累积原文,经与 durable 面**同一只** `redactModelText` —— 这正是本帧存在的理由:
203
+ * 逐 chunk 的 `reasoning_delta` 对**跨 chunk** 的凭据形无能为力(半截 `sk-` 在任何一块里都不成形),
204
+ * 而账本腿攒到 flush 边界的那一整段早就脱得干净;live 面此前没有这份权威副本,壳无可替换。
205
+ * 段界与账本腿**同一只谓词**(`trace/model-segment.ts` 的 `isModelSegmentBoundary`),所以同一次运行里
206
+ * 「账本 `reasoning` 行」与本帧的正文逐字节相等。
207
+ *
208
+ * **空段不发**,判据比 `text_end` 宽一格(那边是 core 契约的「空段不发」,只需判空串):本帧的正文由
209
+ * 本仓攒出,一段**全空白**的思考(模型吐了几个换行就去调工具)没有可替换的权威正文,而消费律是
210
+ * 「整段替换已攒的 delta」—— 发一枚空白权威段 = 让消费端把已渲染的思考清成空白。坏形(`content` 非串)
211
+ * 同样丢帧,理由与 `text_end` 逐字同源。
212
+ */
213
+ export declare function reasoningEndEventData(ev: Record<string, unknown>): Record<string, unknown> | null;
197
214
  /** The durable `tool_end` payload (shared by both append sites). E1: the model-facing `output` is UNTRUSTED RAW
198
215
  * from core (file/shell/API bodies) — core only SIZE-bounds it (`truncated`), REDACTION is ours (same contract as
199
216
  * `tool_start.args`); never re-fed to a model. E2: identity. Absent `output` ⇒ the tool produced no content body.
@@ -176,14 +176,22 @@ function projectSettlement(st) {
176
176
  export function textEndEventData(ev) {
177
177
  if (typeof ev.content !== "string" || ev.content.length === 0)
178
178
  return null;
179
+ return segmentEndFields(ev.content, ev);
180
+ }
181
+ function segmentEndFields(content, ev) {
179
182
  return {
180
- content: redactModelText(ev.content),
183
+ content: redactModelText(content),
181
184
  ...(ev.eventId !== undefined ? { eventId: ev.eventId } : {}),
182
185
  ...(ev.parentToolCallId !== undefined ? { parentToolCallId: ev.parentToolCallId } : {}),
183
186
  ...(ev.sourceTaskId !== undefined ? { sourceTaskId: ev.sourceTaskId } : {}),
184
187
  ...(ev.bgAgentId !== undefined ? { bgAgentId: ev.bgAgentId } : {}),
185
188
  };
186
189
  }
190
+ export function reasoningEndEventData(ev) {
191
+ if (typeof ev.content !== "string" || ev.content.trim().length === 0)
192
+ return null;
193
+ return segmentEndFields(ev.content, ev);
194
+ }
187
195
  export function toolEndEventData(ev) {
188
196
  const gate = ev.gate === undefined ? undefined : screenedGate(ev.gate);
189
197
  return {
@@ -358,7 +358,7 @@ const SHAPE_SECRET_PATTERNS = [
358
358
  [new RegExp(String.raw `xapp-\d-${atLeast("[A-Za-z0-9-]", 20)}`, "g"), "«redacted:slack-token»"],
359
359
  [valueArm("sk-", "[A-Za-z0-9_-]", 16, "self-evident"), "«redacted:key»"],
360
360
  ];
361
- const FULL_SECRET_PATTERNS = [...STRUCTURAL_SECRET_PATTERNS, ...KEYWORD_SECRET_PATTERNS, ...SHAPE_SECRET_PATTERNS];
361
+ const FULL_ARM_PATTERNS = [...STRUCTURAL_SECRET_PATTERNS, ...KEYWORD_SECRET_PATTERNS, ...SHAPE_SECRET_PATTERNS];
362
362
  const PEM_HEADER_IDENT_MAX = 31;
363
363
  const PEM_BODY_RUN_MIN = 8;
364
364
  const isSepCode = (c) => c === CH_SPACE || c === CH_TAB || c === CH_CR || c === CH_LF;
@@ -743,7 +743,7 @@ const FRAGMENT_SECRET_PATTERNS = [
743
743
  [valueArm("(?:gl(?:pat|dt|rt|rtr|cbt|ptt|oas|ft|imt|agent|wt|soat|ffct)-|(?:sk|rk)_(?:live|test|org)_|pypi-)", "[A-Za-z0-9_-]", 8, "needs-left-boundary"), "«redacted:key:partial»"],
744
744
  [valueArm("xapp-\\d-", "[A-Za-z0-9-]", 4, "needs-left-boundary"), "«redacted:slack-token:partial»"],
745
745
  ];
746
- const REDACTION_TOKENS = new Set([...FULL_SECRET_PATTERNS.map(([, r]) => r), PEM_FRAGMENT_TOKEN, ...FRAGMENT_SECRET_PATTERNS.map(([, r]) => r)]
746
+ const REDACTION_TOKENS = new Set([...FULL_ARM_PATTERNS.map(([, r]) => r), PEM_FRAGMENT_TOKEN, ...FRAGMENT_SECRET_PATTERNS.map(([, r]) => r)]
747
747
  .flatMap((r) => r.match(/«[^»]*»/g) ?? []));
748
748
  const REDACTION_TOKEN_MAX = Math.max(...[...REDACTION_TOKENS].map((t) => t.length));
749
749
  {
@@ -802,9 +802,9 @@ const regexRule = ([re, repl]) => {
802
802
  return { run: (text, wantCount) => singleHit(label, arm, applyOnce(text, wantCount)) };
803
803
  };
804
804
  const URL_USERINFO_PATTERN = (() => {
805
- const row = FULL_SECRET_PATTERNS.find(([, repl]) => repl.endsWith("»@"));
805
+ const row = FULL_ARM_PATTERNS.find(([, repl]) => repl.endsWith("»@"));
806
806
  if (row === undefined)
807
- throw new Error("redact: URL-userinfo rule missing from FULL_SECRET_PATTERNS");
807
+ throw new Error("redact: URL-userinfo rule missing from FULL_ARM_PATTERNS");
808
808
  return row;
809
809
  })();
810
810
  const isRegexSpace = (c) => c === 32 || (c >= 9 && c <= 13) || c === 0xa0 || c === 0x1680 || (c >= 0x2000 && c <= 0x200a) || c === 0x2028 || c === 0x2029 || c === 0x202f || c === 0x205f || c === 0x3000 || c === 0xfeff;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.76.2",
3
+ "version": "7.77.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",