@zhushanwen/pi-session-manager 0.1.16 → 0.2.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.
package/src/index.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  // src/index.ts — @zhushanwen/pi-session-manager
2
2
  // 6 个 session 管理工具,通过 ctx.ui.select(SESSION_MANAGER_MARKER) 通道与 runtime handler 通信。
3
3
 
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionContext,
7
+ SessionCompactEvent,
8
+ SessionStartEvent,
9
+ } from "@earendil-works/pi-coding-agent";
5
10
  import {
6
11
  SESSION_MANAGER_MARKER,
7
12
  callMarkerRpc,
@@ -13,6 +18,9 @@ import {
13
18
  import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
14
19
  import { Type, type Static, type TObject } from "typebox";
15
20
 
21
+ import { ensureLedgerBound, runLedgerCompactionCheck } from "./notify-ledger.ts";
22
+ import { createWatchCoordinator } from "./watch-coordinator.ts";
23
+
16
24
  // 模块级 logger(default export 首行 setPiHandle 注入后自动走 appendEntry 持久化,
17
25
  // 注入前/失败降级文件日志——见 extension-logger 三层通道设计)
18
26
  const logger = getLogger("session-manager");
@@ -47,8 +55,11 @@ const AbortSessionParams = Type.Object({
47
55
 
48
56
  // ── select 通道辅助 ──
49
57
 
50
- /** select 超时(ms):工具等待 runtime handler respond 的最大时间(create/history 走长链路放宽) */
51
- const SELECT_TIMEOUT_MS: Record<SessionManagerAction, number> = {
58
+ /** select 超时(ms):工具等待 runtime handler respond 的最大时间(create/history 走长链路放宽)。
59
+ * watch 刻意缺席(notify-once D2:不传 timeout、fire-and-forget 长挂——P1 实测 `if
60
+ * (opts?.timeout)` 才设 timer)。形制 = `Exclude<SessionManagerAction, "watch">`:
61
+ * 协议 action 集 6→7 后漏配键即编译期红(计划 blocker #1 的修复形态)。 */
62
+ const SELECT_TIMEOUT_MS: Record<Exclude<SessionManagerAction, "watch">, number> = {
52
63
  create: 60_000,
53
64
  send: 30_000,
54
65
  history: 60_000,
@@ -60,7 +71,10 @@ const SELECT_TIMEOUT_MS: Record<SessionManagerAction, number> = {
60
71
  /**
61
72
  * 通过 select 通道向 runtime handler 发送 session 管理请求(传输核走 protocol 的
62
73
  * callMarkerRpc 原语,D8)。回包为 handler respond 的 JSON 字符串(value 恒 raw);
63
- * 失败四态(cancelled/timeout/channel-error/non-json)由 executeTool 统一折叠 isError。
74
+ * 失败四态(cancelled/timeout/channel-error/non-json)由 executeTool 统一 throw——
75
+ * pi agent-loop 仅在 execute throw 时置 isError:true,返回值里的 isError 字段被丢弃
76
+ * (pi-agent-core dist/agent-loop.js:453-483 executePreparedToolCall:正常 return
77
+ * 硬编码 isError:false、catch 置 true——0.84.4 实读 :468/:470-476;语义登记 PS-56)。
64
78
  * 通道异常与非 JSON 回包的留痕由原语经注入的 log 承担。
65
79
  */
66
80
  function callSessionManager(
@@ -81,51 +95,63 @@ function callSessionManager(
81
95
  ui: { select: ctx.ui.select.bind(ctx.ui) },
82
96
  };
83
97
  return callMarkerRpc(guiCtx, SESSION_MANAGER_MARKER, payload, {
84
- timeout: SELECT_TIMEOUT_MS[action],
98
+ // watch 不传 timeout(D2/P1:无 timer 长挂不死,任意晚的 respond 按 id 精确 resolve;PS-59)
99
+ timeout: action === "watch" ? undefined : SELECT_TIMEOUT_MS[action],
85
100
  log: (msg, detail) => logger.error(`[session-manager] ${msg}`, detail),
86
101
  });
87
102
  }
88
103
 
104
+ /** notifyId 生成(notify-once D2):`sm-` + UUID4——与协议 isSessionManagerNotifyId
105
+ * 同形态(runtime 受理点按该形态判 arm)。 */
106
+ function newNotifyId(): string {
107
+ return `sm-${crypto.randomUUID()}`;
108
+ }
109
+
110
+ /** 运行时守卫(读侧不信任外部格式):结果是否 JSON object——形状断言前的类型 guard。 */
111
+ function asResultRecord(v: unknown): Record<string, unknown> | undefined {
112
+ return typeof v === "object" && v !== null && !Array.isArray(v)
113
+ ? (v as Record<string, unknown>)
114
+ : undefined;
115
+ }
116
+
89
117
  /**
90
118
  * 统一的 execute 包装:调用 select 通道并解析结果。
91
119
  * 返回标准 AgentToolResult 形状;select 取消/超时/异常/非 JSON 回包是错误路径,
92
- * 必须带 isError: true(extension-conventions「禁止错误成功模式」——
93
- * 调用方 agent 需能区分成功与失败以决定重试/放弃)。
120
+ * 必须 throw(extension-conventions「禁止错误成功模式」——pi 契约里 execute 只有
121
+ * throw 才被置 isError:true,返回值携带 isError 字段会被 agent-loop 丢弃
122
+ * (agent-loop.js:453-483,PS-56),ask-user/scheduler/session-reader 的 W4 throw
123
+ * 范式同款;调用方 agent 需能区分成功与失败以决定重试/放弃)。
94
124
  */
95
125
  async function executeTool(
96
126
  ctx: ExtensionContext,
97
127
  action: SessionManagerAction,
98
128
  params: Record<string, unknown>,
99
- ): Promise<{ isError?: boolean; content: Array<{ type: "text"; text: string }>; details: undefined }> {
129
+ onResult?: (ctx: ExtensionContext, params: Record<string, unknown>, result: unknown) => void,
130
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }> {
100
131
  const result = await callSessionManager(ctx, action, params);
101
132
  if (!result.ok) {
102
133
  // 行为微变①(D8,有意——对齐 plugin-bridge 形态):非 JSON 回包从「catch 后
103
- // parsed=undefined 静默当成功文本返回」改为 isError + 提示文本(留痕由原语
134
+ // parsed=undefined 静默当成功文本返回」改为 throw + 提示文本(留痕由原语
104
135
  // 经注入的 logger.error 承担);其余三态维持原 cancelled/timeout 折叠文案。
105
136
  const text =
106
137
  result.reason === "non-json"
107
138
  ? `Session manager ${action}: non-JSON response from runtime (protocol mismatch — redeploy same-version runtime + extension; see extension logs).`
108
139
  : `Session manager ${action}: cancelled or timed out.`;
109
- return {
110
- isError: true,
111
- content: [{ type: "text" as const, text }],
112
- details: undefined,
113
- };
140
+ throw new Error(text);
114
141
  }
115
142
  const raw = result.value;
116
143
  // 合法性已由原语检测(ok:true ⇒ 同一字符串 JSON.parse 必成功),parse 只为字段检测。
117
144
  // runtime 错误闭环(respond({error}) 走同一 select 通道)——检测与文本拼接单源于
118
- // protocol 的 isChannelErrorResult / formatChannelErrorText(D8):命中即 isError: true
145
+ // protocol 的 isChannelErrorResult / formatChannelErrorText(D8):命中即 throw
119
146
  //(extension-conventions「禁止错误成功模式」:agent 需能区分成功与同步失败以决定
120
147
  // 重试/放弃,不能靠读 content 文本自行判错)。
121
148
  const parsed: unknown = JSON.parse(raw);
122
149
  if (isChannelErrorResult(parsed)) {
123
- return {
124
- isError: true,
125
- content: [{ type: "text" as const, text: formatChannelErrorText(parsed) }],
126
- details: undefined,
127
- };
150
+ throw new Error(formatChannelErrorText(parsed));
128
151
  }
152
+ // 成功路径副作用编排(notify-once U4):send/create 的 willNotify arm(register + 开表)、
153
+ // label 缓存回填(list)——错误路径已 throw,天然不触发。
154
+ onResult?.(ctx, params, parsed);
129
155
  return {
130
156
  content: [{ type: "text" as const, text: raw }],
131
157
  details: undefined,
@@ -143,6 +169,8 @@ interface SessionToolConfig<S extends TObject> {
143
169
  action: SessionManagerAction
144
170
  /** schema params → 协议 params 的映射(undefined 字段由 JSON.stringify 丢弃) */
145
171
  toParams: (params: Static<S>) => Record<string, unknown>
172
+ /** 成功结果钩子(仅非错误通道结果触发)——notify-once 的 arm/label 缓存编排 */
173
+ onResult?: (ctx: ExtensionContext, params: Record<string, unknown>, result: unknown) => void
146
174
  }
147
175
 
148
176
  /**
@@ -163,7 +191,7 @@ function registerSessionTool<S extends TObject>(pi: ExtensionAPI, cfg: SessionTo
163
191
  _onUpdate: unknown,
164
192
  ctx: ExtensionContext,
165
193
  ) {
166
- return executeTool(ctx, cfg.action, cfg.toParams(params));
194
+ return executeTool(ctx, cfg.action, cfg.toParams(params), cfg.onResult);
167
195
  },
168
196
  });
169
197
  }
@@ -172,22 +200,75 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
172
200
  // logger 持久化通道接入(appendEntry custom entry,不进 LLM 上下文)
173
201
  setPiHandle(pi);
174
202
 
203
+ // watch 桥编排(notify-once U4):工具结果 arm / watch 应答两层攒批 record /
204
+ // session_start 重启收口腿共用同一闭包状态(label 缓存 + 死亡新闻槽 + 攒批队列)。
205
+ const coordinator = createWatchCoordinator({
206
+ pi,
207
+ callWatch: (ctx, notifyId) => callSessionManager(ctx, "watch", { notifyId }),
208
+ });
209
+
210
+ // 重启收口腿(D7③):父 pi 死亡后永无 unregister 的活跃 type 'session' register
211
+ // 逐条重开 watch(入参 = entry id 即 notifyId),应答经 watch 处理链静默注销,
212
+ // 活跃集回归基线;每 entry 独立 handler(禁串行 await)在 coordinator 内保证。
213
+ // ledger 装配纪律①②(D3):session_start 无条件 bind + recoverFromSession 成对
214
+ //(唯一装配方形态,与 subagent-workflow bindLedgerHostAndRecover 同款——先装配,
215
+ // 收口腿/watch 应答到达时消费面已就绪)。
216
+ pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
217
+ ensureLedgerBound(pi, ctx);
218
+ coordinator.recoverStaleRegisters(ctx);
219
+ });
220
+ // compactionCheck 接线(D3 装配纪律④,P-B4 降级——subagent-workflow 同款先例)
221
+ pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
222
+ runLedgerCompactionCheck();
223
+ });
224
+
175
225
  registerSessionTool(pi, {
176
226
  name: "create_managed_session",
177
227
  label: "Create Managed Session",
178
- description: "Create a new agent-managed session in the specified working directory. Optionally provide an initial prompt, which is sent immediately (new sessions are always idle, so it is delivered directly). Returns a session ID and initial status. Requires the taiji desktop runtime; standalone pi CLI will time out.",
228
+ description:
229
+ "Create a new agent-managed session in the specified working directory. Optionally provide an initial prompt, which is sent immediately (new sessions are always idle, so it is delivered directly). Returns a session ID, initial status and willNotify: when a prompt is provided, exactly one completion notification is delivered to this session after that run finishes (see pending_notifications, type 'session'); without a prompt willNotify is false and no completion notification is sent. Terminal session death is notified once regardless. Requires the taiji desktop runtime; standalone pi CLI will time out.",
179
230
  parameters: CreateManagedSessionParams,
180
231
  action: "create",
181
- toParams: (p) => ({ cwd: p.cwd, label: p.label, prompt: p.prompt }),
232
+ toParams: (p) => ({
233
+ cwd: p.cwd,
234
+ label: p.label,
235
+ prompt: p.prompt,
236
+ // 债权只可能在带 prompt 时产生(G1)——无 prompt 不携带 notifyId(缺省即不 arm)
237
+ notifyId: p.prompt !== undefined ? newNotifyId() : undefined,
238
+ }),
239
+ onResult: (ctx, params, result) => {
240
+ const r = asResultRecord(result);
241
+ if (!r || typeof r.sessionId !== "string") return;
242
+ const rawLabel =
243
+ typeof params.label === "string" && params.label !== "" ? params.label : undefined;
244
+ const name = rawLabel ?? r.sessionId;
245
+ coordinator.noteLabel(r.sessionId, name);
246
+ const notifyId = params.notifyId;
247
+ if (r.willNotify === true && typeof notifyId === "string") {
248
+ coordinator.armSessionNotify(ctx, notifyId, name);
249
+ }
250
+ if (typeof r.lifetimeNotifyId === "string") {
251
+ coordinator.armSessionNotify(ctx, r.lifetimeNotifyId, name);
252
+ }
253
+ },
182
254
  });
183
255
 
184
256
  registerSessionTool(pi, {
185
257
  name: "send_to_session",
186
258
  label: "Send to Session",
187
- description: "Send a prompt/message to an existing managed session. The message is asynchronously queued: if the target session is busy (generating/compacting/running bash) it is delivered at its next turn boundary, and {queued: true} is returned immediately. On synchronous failure the tool returns an error result (isError) with a hint (check get_session_status, then retry). Requires the taiji desktop runtime; standalone pi CLI will time out.",
259
+ description:
260
+ "Send a prompt/message to an existing managed session. The message is asynchronously queued: if the target session is busy (generating/compacting/running bash) it is delivered at its next turn boundary, and {queued: true} is returned immediately. Returns willNotify: exactly one completion notification is delivered to this session when the queued message is consumed and that run finishes (several queued messages merge into a single notification with a count). On synchronous failure the tool call fails with an error (check get_session_status, then retry). Requires the taiji desktop runtime; standalone pi CLI will time out.",
188
261
  parameters: SendToSessionParams,
189
262
  action: "send",
190
- toParams: (p) => ({ sessionId: p.sessionId, prompt: p.prompt }),
263
+ toParams: (p) => ({ sessionId: p.sessionId, prompt: p.prompt, notifyId: newNotifyId() }),
264
+ onResult: (ctx, params, result) => {
265
+ const r = asResultRecord(result);
266
+ if (!r || r.willNotify !== true) return;
267
+ const notifyId = params.notifyId;
268
+ const sessionId = params.sessionId;
269
+ if (typeof notifyId !== "string" || typeof sessionId !== "string") return;
270
+ coordinator.armSessionNotify(ctx, notifyId, coordinator.labelFor(sessionId));
271
+ },
191
272
  });
192
273
 
193
274
  registerSessionTool(pi, {
@@ -206,6 +287,20 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
206
287
  parameters: ListMySessionsParams,
207
288
  action: "list",
208
289
  toParams: () => ({}),
290
+ // label 缓存回填(D9 正常路径 label 源的兜底——进程重启后 create 缓存丢失,
291
+ // send 的文案/register name 退化为 sessionId 前先经 list 恢复真 label)
292
+ onResult: (_ctx, _params, result) => {
293
+ const r = asResultRecord(result);
294
+ if (!r || !Array.isArray(r.sessions)) return;
295
+ for (const item of r.sessions) {
296
+ const s = asResultRecord(item);
297
+ if (!s || typeof s.id !== "string") continue;
298
+ coordinator.noteLabel(
299
+ s.id,
300
+ typeof s.label === "string" && s.label !== "" ? s.label : s.id,
301
+ );
302
+ }
303
+ },
209
304
  });
210
305
 
211
306
  registerSessionTool(pi, {
@@ -0,0 +1,77 @@
1
+ // src/notify-content.ts — managed session 完成/死亡通知文案与 details 构造(notify-once D9/U4)。
2
+ //
3
+ // buildBackflowContent 迁入(U3 裁决:completion-backflow 废弃后 extension 是唯一消费方,
4
+ // extension 不能反向 import runtime 包)。形状保持现 backflow 文案:
5
+ // Managed session "<label>" (<sid>) finished with status "<status>"[ (exit code: N)].[ (fulfills N request(s))]
6
+ // Stderr: <tail 400 字截尾> (stderr 非空时)
7
+ // Full transcript: <sessionFilePath>(watch respond payload 已携该字段——u-protocol
8
+ // d69583684;runtime 查得到即携、查不到不携,不携时整行省略语义不变)。
9
+ //
10
+ // label 两路径(U4):正常路径 = 工具入参 label(create 入参 / send 经 labelCache 回填);
11
+ // 重启收口腿 = pending:register 三键的 name。两条路径都汇入本函数的 params.label,
12
+ // deathSeq 后 kind 不再决定文案(D3 例外2 内容确定性)。
13
+
14
+ /** managed session 完成通知的送达 customType(D9)。与 packages/shared
15
+ * COMPLETE_NOTIFY_CUSTOM_TYPES 追加值、core extractNotifyRecords 分支字面量三处同值,
16
+ * 改名需三处同步(shared 为渲染侧 SSOT,本包不能 import @taiji/shared——非依赖)。 */
17
+ export const MANAGED_SESSION_NOTIFY_CUSTOM_TYPE = "managed-session-notify";
18
+
19
+ /** stderr 摘要上限(截尾,迁移自 completion-backflow——诊断价值 > 完整性,防爆量撑爆文案) */
20
+ const STDERR_TAIL_LIMIT = 400;
21
+
22
+ export interface ManagedNotifyContentParams {
23
+ /** 会话标签(两路径汇入点,见文件头) */
24
+ label: string;
25
+ /** 子会话 id(watch respond payload 回带) */
26
+ sessionId: string;
27
+ /** 通知状态(settle: completed/failed/stopped;death: exited/deleted) */
28
+ status: string;
29
+ /** 本批销账的债权笔数(D3 合批 fulfills N;0/undefined → 不出行;纯死亡通知无债权) */
30
+ fulfills?: number;
31
+ /** transcript 指针行数据源(watch respond payload.sessionFilePath——u-protocol d69583684;
32
+ * runtime 查不到不携 → 缺席时整行省略) */
33
+ sessionFilePath?: string;
34
+ /** death 应答携带(D6 meta,exit 腿诊断通路复刻) */
35
+ exitCode?: number | null;
36
+ stderrTail?: string;
37
+ }
38
+
39
+ /**
40
+ * 构造通知正文(对齐 notifier buildLlmContent 模式:label/status + 指针行)。
41
+ * 纯函数,零 I/O——U6_UNIT 同族测试随迁(原 completion-backflow.test.ts 的纯函数组)。
42
+ */
43
+ export function buildManagedNotifyContent(params: ManagedNotifyContentParams): string {
44
+ const exitNote =
45
+ params.exitCode !== undefined ? ` (exit code: ${params.exitCode ?? "null"})` : "";
46
+ const fulfillsNote =
47
+ params.fulfills !== undefined && params.fulfills > 0
48
+ ? ` (fulfills ${params.fulfills} request${params.fulfills === 1 ? "" : "s"})`
49
+ : "";
50
+ let content = `Managed session "${params.label}" (${params.sessionId}) finished with status "${params.status}"${exitNote}.${fulfillsNote}`;
51
+ if (params.stderrTail && params.stderrTail.trim() !== "") {
52
+ const tail =
53
+ params.stderrTail.length > STDERR_TAIL_LIMIT
54
+ ? params.stderrTail.slice(-STDERR_TAIL_LIMIT)
55
+ : params.stderrTail;
56
+ content += `\nStderr: ${tail.trim()}`;
57
+ }
58
+ if (params.sessionFilePath) {
59
+ content += `\nFull transcript: ${params.sessionFilePath}`;
60
+ }
61
+ return content;
62
+ }
63
+
64
+ /**
65
+ * record 的投递 details(ledger record 第三参)——core `extractNotifyRecords`
66
+ * 的 `managed-session-notify` 分支读 `notifyId`(去重键)与 `reason`(outcome 映射
67
+ * completed→success / failed→failed / 其余 neutral,D9)。其余字段为诊断冗余。
68
+ */
69
+ export interface ManagedNotifyDetails {
70
+ /** 本批 record 的幂等键 = 批内首个响应的 notifyId(≤1 record/批由合批边界保证) */
71
+ notifyId: string;
72
+ sessionId: string;
73
+ reason: string;
74
+ /** 批内销账笔数(runtime fulfillsN 直供,缺省回退批长——见 watch-coordinator) */
75
+ fulfills: number;
76
+ label: string;
77
+ }
@@ -0,0 +1,121 @@
1
+ // src/notify-ledger.ts — notify-ledger(B-ledger)装配与 record 消费(notify-once D3 装配纪律)。
2
+ //
3
+ // canonical 消费面(D-10 领地补录后落地):@zhushanwen/subagent-core 的
4
+ // getBoundNotifyLedger / bindNotifyLedgerHost——Symbol.for 进程级单例槽跨扩展 bundle
5
+ // 共享同一 ledger 实体(C-ext-06),本包不再持有任何槽镜像。
6
+ //
7
+ // 装配纪律(D3)对照(session_start 无条件重装配方,与 subagent-workflow
8
+ // bindLedgerHostAndRecover 同款;旧「查槽短路」形态已废):
9
+ // ① session_start 无条件 bindNotifyLedgerHost(host).recoverFromSession() 成对——host
10
+ // 捕获当次 session 的 pi/ctx,不查槽短路(与 subagent-core 每次 session_start 重
11
+ // bind 的设计假设对齐:槽上永不残留捕获旧 ctx 的 host);
12
+ // ② recover 吸收 session 文件全部既有条目(G3 重启重放即 recoverFromSession 语义);
13
+ // ③ 双 bind 收敛:后 bind 者 dispose 前实例 + recover → 单实例全量态,前者的已落盘
14
+ // 条目无损(record 先 appendEntry 落盘后内存更新——notify-ledger-discipline 用例钉住)。
15
+ // 收敛由 subagent-core bindNotifyLedgerHost 的 dispose 旧实例语义保证,装配方无需
16
+ // 自查槽(subagent-core 侧 bind 用模块级单例 handler + boundLedger 引用切换,
17
+ // 重 bind 不累积物理监听);
18
+ // ④ compactionCheck 同批接线(session_compact handler,P-B4 降级——
19
+ // subagent-workflow 同款先例;两侧对同一槽实例重复调用为幂等 no-op);
20
+ // ⑤ 消费点恒经 getBoundNotifyLedger() 动态查槽,禁缓存实例引用——后 bind 方 dispose
21
+ // 旧实例后缓存引用的 record() 静默 return false 零日志,对半概率丢全部通知。
22
+ //
23
+ // 唯一装配方形态(拓扑假设):taiji runtime 每 session 重 spawn pi 进程,进程内无
24
+ // session 替换——session_start 触发时本包是该 session ledger 的唯一装配方(与
25
+ // subagent-workflow 共存时按 ③ 收敛)。stale ctx 面因此收窄,sendDelivery 的
26
+ // guardStaleCtx 为防御性兜底(异常形态留痕归因,非正常路径承重)。
27
+ //
28
+ // 槽空降级(record 时点仍无实例:session_start 未跑 / bind 失败):warn 留痕 + false
29
+ //(STANDARDS §11.1 通知类接入点降级,非静默吞)。
30
+
31
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
+ import { bindNotifyLedgerHost, getBoundNotifyLedger } from "@zhushanwen/subagent-core";
33
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
34
+ import { createPiNotifyLedgerHost } from "@zhushanwen/pi-notify-ledger-host";
35
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
36
+
37
+ import {
38
+ MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
39
+ type ManagedNotifyDetails,
40
+ } from "./notify-content.ts";
41
+
42
+ const logger = getLogger("session-manager");
43
+
44
+ /**
45
+ * 装配纪律 ①②(session_start 调用):无条件 bind + recover 成对。
46
+ * host 五端口的 pi 接线(appendEntry / getEntries / isIdle / agent_settled /
47
+ * sendMessage + 送达 stale 防御)收敛在共享工厂 @zhushanwen/pi-notify-ledger-host
48
+ * ——与 subagent-workflow 同一工厂(同一槽的两处装配方,host 接口一致才能互换
49
+ * 消费),本包以 component/logger/warn 前缀参数化(label = session-manager:*、
50
+ * stale warn 文案带 "[session-manager] " 前缀,归因不串包)。
51
+ * 装配失败不阻断 session_start(record 侧走槽空降级)。
52
+ */
53
+ export function ensureLedgerBound(pi: ExtensionAPI, ctx: ExtensionContext): void {
54
+ try {
55
+ // ①② bind + recover 成对(G3 重启重放 = recoverFromSession 既有语义;
56
+ // 双 bind 收敛 = subagent-core dispose 旧实例语义,见文件头 ③)
57
+ bindNotifyLedgerHost(
58
+ createPiNotifyLedgerHost(pi, ctx, {
59
+ component: "session-manager",
60
+ logger,
61
+ staleWarnPrefix: "[session-manager] ",
62
+ }),
63
+ ).recoverFromSession();
64
+ } catch (err) {
65
+ logger.warn("[session-manager] notify ledger bind failed (record degrades until next session_start)", {
66
+ error: toErrorMessage(err),
67
+ });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 记账一条通知(B-ledger ① record,deliveryCustomType 走 managed-session-notify 外部通道,
73
+ * 逐条投递不合批——合批已在 watch-coordinator 微窗内重建批身份,D3 裁决4)。
74
+ * 装配纪律⑤:每次调用即到即读查槽,禁存实例引用;槽空 → warn 降级 + false。
75
+ */
76
+ export function recordManagedNotify(
77
+ notifyId: string,
78
+ content: string,
79
+ details: ManagedNotifyDetails,
80
+ ): boolean {
81
+ const ledger = getBoundNotifyLedger();
82
+ if (!ledger) {
83
+ logger.warn(
84
+ "[session-manager] notify ledger not bound — notification dropped (ensureLedgerBound 未跑或 bind 失败;下次 session_start 重试装配)",
85
+ { notifyId },
86
+ );
87
+ return false;
88
+ }
89
+ try {
90
+ return ledger.record(notifyId, content, details, {
91
+ deliveryCustomType: MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
92
+ });
93
+ } catch (err) {
94
+ // 写入异常(stale ctx / appendEntry 失败)按接入点降级:不拖垮 watch 应答编排
95
+ //(unregister 照常),warn 留痕可归因
96
+ logger.warn("[session-manager] notify ledger record failed", {
97
+ notifyId,
98
+ error: toErrorMessage(err),
99
+ });
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * compactionCheck 接线(装配纪律④,P-B4 降级——subagent-workflow session_compact 同款):
106
+ * 检测 ledger/ack entry 被 compaction 清除时按内存态补写(未清除即 0,幂等 no-op)。
107
+ */
108
+ export function runLedgerCompactionCheck(): void {
109
+ try {
110
+ const rewritten = getBoundNotifyLedger()?.compactionCheck() ?? 0;
111
+ if (rewritten > 0) {
112
+ logger.warn(
113
+ `[session-manager] notify ledger entries lost to compaction; rewrote ${rewritten} from memory`,
114
+ );
115
+ }
116
+ } catch (err) {
117
+ logger.warn("[session-manager] notify ledger compactionCheck failed", {
118
+ error: toErrorMessage(err),
119
+ });
120
+ }
121
+ }