@shgroup/dsh-serenity-hooks 1.39.1 → 1.39.3

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/README.md CHANGED
@@ -270,10 +270,11 @@ DSH 一个进程可以同时带多个工作区,每个工作区各自对接自
270
270
 
271
271
  **定时唤醒**:用 `container_trajectory send-later` 登记一条"唤醒" = 未来某时刻 + 一条消息——可以给自己预约,也可以唤醒别的轨迹;落在工作区内的 `AGENT_SESSIONS/wake-registry.json`(可读可审计),到点由中心调度器投递,**不阻塞、不等待、也不回执**。
272
272
 
273
- **即时投递**:用 `container_trajectory send-now` 把一条消息**现在**就递过去——目标在内存里就**立即注入**;不在内存里就**冷载入后投递**(**等效于直接唤醒**,但**不等调度器的 5 分钟节拍**)。它**有同步回执**(告诉你走了 live 还是冷载入),**不落注册表**(那张表专管"未来时刻")。
273
+ **即时投递**:用 `container_trajectory send-now` 把一条消息**现在**就递过去,并且**不排队**——目标在内存里且**正在跑轮次**,就 `steer` **当场注入当前轮**;目标空闲就**立即起一轮**;目标不在内存里就**冷载入后投递**(**等效于直接唤醒**,但**不等调度器的 5 分钟节拍**)。它**有同步回执**(告诉你走了 live 还是冷载入),**不落注册表**(那张表专管"未来时刻")。
274
274
 
275
275
  - 两者只差两处:**时刻**(现在 / 未来)与**回执**(有 / 无)。"预约"天生是 fire-and-forget(不可回收、不回执),"递话"则是一次调用一次答复——**各自语义干净,不把两种语义塞进同一个动作**(🔴 **更名**:这对动作原名 `wake-later` / `send-message`,现名 **`send-later` / `send-now`**——族名取共享词干 `send-`、轴取 `-later`/`-now`,因为「唤醒」是两条路径**共有**的属性,不配做区分;**硬切无别名**)
276
- - ⚠️ **回执只到"已入队"**:它**不表示**目标已经执行或答复;目标正在跑轮次时,消息在**轮次边界**生效(**不打断当前轮**)。要确证"目标真的动了",得看它自己的 `SESSION.md`
276
+ - ⚠️ **回执只到"已注入 / 已起轮"**:它**不表示**目标已经执行或答复。要确证"目标真的动了",得看它自己的 `SESSION.md`(**不得凭回执结案**)
277
+ - ⚠️ **唯一的例外路径**:`steer` 不可用时**退回排队**(fail-safe:**宁可排队,不可静默丢消息**)——此时回执文案写明"steer 不可用 ⇒ 退回 followup"。**`send-later` 不受影响**:预约的本职是"到点唤起",在跑时排队不打断当前轮是它被实证验收过的行为
277
278
 
278
279
  - **目标没打开也能唤醒**:会话不在内存里时先把它载入再投递(**冷唤醒**);载入不了则条目留在登记表里并记下原因,**不静默丢弃**
279
280
  - **"周期"归工作区自己**:要一轮接一轮,就由**收到唤醒的那条轨迹**在每轮结束时再排一次下一轮——ACC 只提供"到点投递一条消息"这个原语,不替工作区决定节拍(焦点、节拍、偏见都由工作区自定;任意条轨迹并行、互不干扰)
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-serenity-hooks",
3
- "version": "1.39.1",
3
+ "version": "1.39.3",
4
4
  "main": "lib/index.js",
5
5
  "description": "宁静号 ACC harness(Native Cordis 插件):给 DSH 装一个「AI 工作区」——11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag;后两个按配置条件出现)+ 机械约束(安全模式/工作区围墙/密钥守卫)+ 轨迹日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2(deepseek-ai/deepseek-harness)。",
6
6
  "engines": {
package/lib/index.js CHANGED
@@ -2,11 +2,12 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { a as hostSettings, c as hostWebServer, i as hostSessions, n as hostInjected, o as hostSubagents, r as hostService, s as hostWeb, t as hostAgents } from "./access-fiehjxV6.js";
3
3
  import { a as findSerenityRoot, c as matchBlacklist, d as readCccName$1, f as readExclusiveTools, h as resolveInside, i as findGitRoot, l as pathInside, n as SAFE_MODE_MARKER, o as isSafeModeOn, p as readHandymanConfig, r as classifyPath, s as loadSerenityConfig, t as DEFAULT_SERENITY_CONFIG_PATHS, u as readBlacklist } from "./ccc-NlLr_sxy.js";
4
4
  import { i as cccRootForExec, n as agentCwdFor, o as listCccs, r as cccRootForCwd, t as NO_CCC_FROM_AGENT_CWD } from "./ccc-roots-Bd_SjEs7.js";
5
+ import { C as useSession, S as summarize, T as localFileStamp, _ as resolveSessionByTitle, a as resolveSessionTrajectoryLabel, b as setActiveSessionInfo, c as TRAJECTORY_ACTIONS, d as extractSessionMdPathFromText, f as findSession, g as readActiveSessionMd, h as parseSessionContextFromEvents, i as readLastBound, l as clearActiveSessionInfo, m as listSessions, o as supersedeOtherBindings, p as getActiveSessionInfo, r as pruneMissingBindings, s as DEFAULT_SESSION_SCOPE, t as appendBound, u as createSession, v as sessionEvents$1, w as isoLocal, x as showSession, y as sessionsRoot } from "./trajectory-bound-B9o_4CMF.js";
5
6
  import { a as readWeixinCredential, c as weixinInboundDir, d as LOCALSTORE_SCOPES, f as checkLocalstoreGitCompliance, g as runLocalStore, h as readStore, i as matchWeixinRoute, l as weixinSessionIdFor, m as readGitTrack, n as extractWeixinText, o as readWeixinSettings, p as localstorePath, r as hasVoiceItem, s as sanitizeFileName$1, t as extractWeixinMedia } from "./weixin-route-B2ajFypI.js";
6
7
  import { a as readSkiffRoles, c as roleMsmWhitelist, d as systemPromptSource, f as trajectorySubset, i as isSkiffSessionId, l as roleToolWhitelist, n as buildSkiffBasePrompt, o as resolveRoleSystemPrompt, r as createRolePromptReader, s as resolveSkiffKind } from "./skiff-role-CEHL5cek.js";
7
8
  import { a as unregisterSkiffSession$1, i as skiffSessionSnapshot$1, n as skiffRoleFor$1, r as skiffSessionInfo$1, t as registerSkiffSession$1 } from "./skiff-registry-FTjoWQTQ.js";
8
- import { _ as sessionsRoot, a as DEFAULT_SESSION_SCOPE, b as summarize, c as createSession, d as getActiveSessionInfo, f as listSessions, g as sessionEvents$1, h as resolveSessionByTitle, i as resolveSessionTrajectoryLabel, l as extractSessionMdPathFromText, m as readActiveSessionMd, o as TRAJECTORY_ACTIONS, p as parseSessionContextFromEvents, r as readLastBound, s as clearActiveSessionInfo, t as appendBound, u as findSession, v as setActiveSessionInfo, x as useSession, y as showSession } from "./trajectory-bound-CApZRbvx.js";
9
- import { a as WAKE_CATCH_UP_MS, i as registerDisposer, n as sendToTrajectory, o as addWake, r as wakeSchedulerState, s as listWakes, t as registerWakeScheduler } from "./wake-scheduler-B50eu0jb.js";
9
+ import { r as sessionsRootDir, t as hasSessionLogById } from "./session-cleanup-BtkSmry8.js";
10
+ import { a as WAKE_CATCH_UP_MS, i as registerDisposer, n as sendToTrajectory, o as addWake, r as wakeSchedulerState, s as listWakes, t as registerWakeScheduler } from "./wake-scheduler-CCpCk-7r.js";
10
11
  import { a as FACE_PORTS, c as MECHANISM_PORTS, i as ACP_HTTP_PORT, l as SKIFF_DEBUG_PORT, n as registerSettingsSection, o as GATEWAY_PORT, s as MAIN_WEB_PORT, t as readSimpleSettings, u as WEIXIN_SEND_PORT } from "./settings-section-DMDUoQum.js";
11
12
  import { a as markdownToPlainText, c as sendTyping, i as getUpdates, l as sniffImageExt, n as downloadMedia, o as sendFileMessage, r as getConfig, s as sendTextMessage, t as TypingStatus } from "./weixin-api-CtFYm53S.js";
12
13
  import z from "@deepseek-ai/schemastery";
@@ -73,7 +74,7 @@ function getFileInfo(absPath, name) {
73
74
  type: detectFileType(stat),
74
75
  size: stat.size,
75
76
  sizeHuman: humanSize(stat.size),
76
- mtime: stat.mtime.toISOString()
77
+ mtime: isoLocal(stat.mtime)
77
78
  };
78
79
  } catch {
79
80
  return {
@@ -356,7 +357,7 @@ function runCcFs(root, args) {
356
357
  `path: ${safeRel(root, absPath)}`,
357
358
  `type: ${fileType}`,
358
359
  `size: ${stat.size} (${humanSize(stat.size)})`,
359
- `mtime: ${stat.mtime.toISOString()}`,
360
+ `mtime: ${isoLocal(stat.mtime)}`,
360
361
  `mode: ${modeStr}`,
361
362
  `uid: ${stat.uid}`,
362
363
  `gid: ${stat.gid}`
@@ -552,7 +553,7 @@ async function runKit(root, args, hostCtx) {
552
553
  case "time": {
553
554
  const now = /* @__PURE__ */ new Date();
554
555
  return {
555
- now_iso: now.toISOString(),
556
+ now_iso: isoLocal(now),
556
557
  now_local: now.toString(),
557
558
  epoch_ms: now.getTime()
558
559
  };
@@ -965,7 +966,7 @@ Directory of ACC capabilities — each area lists where to go for details (guide
965
966
  专属工具(**默认对所有 CCC 隐藏**,只有在自己 exclusiveTools 里点名的 CCC 可见)→ acc-diag(运行态诊断)
966
967
  ④ 角色与对外面 → container_admin role(Skiff 认知子集角色:guide/validate/apply/list——skiff_admin 已并入)
967
968
  ACP(程序化 JSON-RPC 3100)/ Skiff 问答页(公网 ask)——面板「外部能力」组
968
- ⑤ 自主与接入 → container_trajectory send-now(跨轨迹的**即时**递送:live 目标立即注入,非 live 冷载入——等效于直接 wake;**有同步回执**)
969
+ ⑤ 自主与接入 → container_trajectory send-now(跨轨迹的**即时**递送,**不排队**:live 目标在跑就当场注入当前轮、空闲则立即起轮;非 live 冷载入——等效于直接 wake;**有同步回执**)
969
970
  container_trajectory send-later(跨轨迹的"未来时刻 + 一条 message";fire-and-forget,无回执无回收)
970
971
  weixin 微信桥(配置/扫码/路由/消息 hook——CCC 侧 weixin-doctor MSM:msm("weixin-doctor", ["guide"]))
971
972
  ⑥ CCC 配置总览 → container_admin msm ccc-config(9 段:handyman/sessionKeeper/localstore/hooks/safeMode/skiff/trajectory/weixin/**exclusiveTools**)
@@ -1257,7 +1258,27 @@ function loadMsmEntries(root) {
1257
1258
  function findEntry(root, name) {
1258
1259
  return loadMsmEntries(root).find((e) => e.name === name) ?? null;
1259
1260
  }
1260
- /** 扫描各 skill scripts/ 下的非测试脚本(DC-M3 正向基准,对齐 osp) */
1261
+ /**
1262
+ * 扫描各 skill `scripts/` 下的**未注册候选**(DC-M3 正向基准,对齐 osp)。
1263
+ *
1264
+ * 🔴 **入口判据**(S151 轮 112 跨轨迹信号 → S142 实测精化,2026-09-17;取证见 S142 §0q):
1265
+ * · **入口 ⟺ 有 shebang(`^#!`) ∨ 声明了 `function main(`** —— **`process.argv` 单独不算**。
1266
+ * 反例(实测):`home-desk/scripts/lib/desk-dispatch.ts` 含 **5** 处 `process.argv`,
1267
+ * 但无 shebang / 无 `main()`,且被 `desk-window.ts`、`desk-vision.ts` **import** ⇒ **它是库**。
1268
+ * · **库 ⟺ 无 shebang ∧ 无 `main()` ∧ 被其它脚本 import** ⇒ **不是 MSM 候选,不上报**。
1269
+ * ⚠️ **"有 `.test.ts`" 不是入口证据** —— 库**同样**有测试
1270
+ * (`home-serenity/scripts/dev-kit/dev-kit.test.ts` 开头自述"**共享库单测**")。
1271
+ * 📌 实现只测**前两条**(shebang / `main()`):第三条"被 import"是**描述性**的,不作判据 ——
1272
+ * 因为**无 CLI 入口的文件本来就不可能被 `msm()` 调用**,上报它只会得到一条无法处置的欠账。
1273
+ * 🔗 **与 DC-M2 自洽**:DC-M2 本就要求**已注册**脚本必须有 main() 守卫 ⇒ 把"无入口"的文件
1274
+ * 排除出候选,与注册表的契约**同向**(两条判据不会互相打架)。
1275
+ *
1276
+ * 🔴 **本函数只扫 `scripts/` 一层(非递归),这是当前刻意的语义**(嵌套子域对容器不可见;
1277
+ * 见 CCC `docs/t024-msm-skill-inventory.md` §3b)。**若将来改为递归,上面的判据就是护栏**:
1278
+ * 嵌套候选实测 **8 个**(`dev-kit/` 5 + `home-desk/scripts/lib/` 3)**全部是库**——
1279
+ * 不按判据过滤,一次递归就会制造 8 条假欠账,并可能诱使后续轮次把库注册成**假 MSM**。
1280
+ * ⇒ **改递归前先读本条注释。**
1281
+ */
1261
1282
  function scanSkillScripts(root) {
1262
1283
  const out = [];
1263
1284
  const skillsDir = join(root, ".opencode", "skills");
@@ -1265,10 +1286,34 @@ function scanSkillScripts(root) {
1265
1286
  for (const skill of readdirSync(skillsDir)) {
1266
1287
  const scriptsDir = join(skillsDir, skill, "scripts");
1267
1288
  if (!existsSync(scriptsDir)) continue;
1268
- for (const f of readdirSync(scriptsDir)) if (/\.(ts|js|mjs)$/.test(f) && !/\.(test|spec)\./.test(f)) out.push(join(".opencode", "skills", skill, "scripts", f));
1289
+ for (const f of readdirSync(scriptsDir)) {
1290
+ const abs = join(scriptsDir, f);
1291
+ let isFile = false;
1292
+ try {
1293
+ isFile = statSync(abs).isFile();
1294
+ } catch {
1295
+ continue;
1296
+ }
1297
+ if (!isFile) continue;
1298
+ if (!/\.(ts|js|mjs)$/.test(f)) continue;
1299
+ if (/\.(test|spec)\./.test(f)) continue;
1300
+ if (!hasCliEntry(abs)) continue;
1301
+ out.push(join(".opencode", "skills", skill, "scripts", f));
1302
+ }
1269
1303
  }
1270
1304
  return out.sort();
1271
1305
  }
1306
+ /** 入口判据的机械实现(判据、反例与递归化护栏见 `scanSkillScripts` 注释) */
1307
+ function hasCliEntry(absPath) {
1308
+ let text;
1309
+ try {
1310
+ text = readFileSync(absPath, "utf-8");
1311
+ } catch {
1312
+ return true;
1313
+ }
1314
+ if (/^#!/.test(text)) return true;
1315
+ return /\b(?:async\s+)?function\s+main\s*\(/.test(text);
1316
+ }
1272
1317
  /**
1273
1318
  * **注册表「质量契约」判据(DC-M1~M4)** —— 具名出口(C5 观察面归一,2026-09-15)。
1274
1319
  *
@@ -1666,7 +1711,7 @@ function writeProgress(root, label, p) {
1666
1711
  writeFileSync(json, JSON.stringify({
1667
1712
  ...p,
1668
1713
  status: p.status ?? "running",
1669
- updated: (/* @__PURE__ */ new Date()).toISOString()
1714
+ updated: isoLocal()
1670
1715
  }, null, 2) + "\n", "utf-8");
1671
1716
  const lines = [
1672
1717
  `# handyman: ${label}`,
@@ -1694,7 +1739,7 @@ function writeFailedStatus(root, label, info) {
1694
1739
  status: "failed",
1695
1740
  errorCode: info.errorCode,
1696
1741
  errorMessage: info.errorMessage,
1697
- updated: (/* @__PURE__ */ new Date()).toISOString(),
1742
+ updated: isoLocal(),
1698
1743
  lastResponse: prev?.lastResponse ?? ""
1699
1744
  }, null, 2) + "\n", "utf-8");
1700
1745
  }
@@ -1821,7 +1866,7 @@ function listActiveHandymen(root) {
1821
1866
  });
1822
1867
  } catch {}
1823
1868
  }
1824
- out.sort((a, b) => a.updated < b.updated ? 1 : -1);
1869
+ out.sort((a, b) => Date.parse(b.updated) - Date.parse(a.updated));
1825
1870
  return out;
1826
1871
  }
1827
1872
  //#endregion
@@ -2808,7 +2853,7 @@ async function runHandymanJob(ctx, opts) {
2808
2853
  done: false,
2809
2854
  label,
2810
2855
  model,
2811
- updated: (/* @__PURE__ */ new Date()).toISOString(),
2856
+ updated: isoLocal(),
2812
2857
  lastResponse
2813
2858
  };
2814
2859
  writeProgress(root, label, progress);
@@ -2824,7 +2869,7 @@ async function runHandymanJob(ctx, opts) {
2824
2869
  done,
2825
2870
  label,
2826
2871
  model,
2827
- updated: (/* @__PURE__ */ new Date()).toISOString(),
2872
+ updated: isoLocal(),
2828
2873
  lastResponse,
2829
2874
  status: done ? "done" : "running"
2830
2875
  });
@@ -3125,7 +3170,7 @@ function renderText$3(value) {
3125
3170
  function createTrajectoryTool(ctx) {
3126
3171
  return defineTool({
3127
3172
  name: "container_trajectory",
3128
- description: "Trajectory (AGENT_SESSIONS/ — the persistent body of a trajectory; the dsh conversation is only its rebuildable carrier). Lifecycle + delivery: list (inventory with stats and anomaly marks) / show (read one SESSION.md) / create / use (activate for this conversation, with inline integrity check) / rebuild (clear-and-rebuild the current conversation in place, Ship of Theseus) / send-now (deliver ONE message to a trajectory RIGHT NOW — live: injected immediately; not live: cold-resumed, i.e. equivalent to a direct wake; returns a synchronous receipt) / send-later (schedule ONE message to any trajectory at a future instant — fire-and-forget: no receipt, no recall, no panel). create requires --desc <desc> [--goal] or --issue <ticket> (exactly one) plus --summary (≤20 chars). use requires --summary (≤20 chars) and may pass --force to switch away from the currently-bound trajectory. rebuild requires --summary (next-phase summary ≤20 chars) + optional --note (task focus for the rebuilt self). send-later requires target (S### or AGENT_SESSIONS dir name) + at (RFC3339 or +30m/+2h) + message. send-now requires target + message. Its receipt means the message was QUEUED into the target (it does NOT mean the target ran or replied; a busy target applies it at its next round boundary). The summary is appended to the dsh session title (S###-YYYY-MM-DD-<summary>); the S### id and date stay server-derived.",
3173
+ description: "Trajectory (AGENT_SESSIONS/ — the persistent body of a trajectory; the dsh conversation is only its rebuildable carrier). Lifecycle + delivery: list (inventory with stats and anomaly marks) / show (read one SESSION.md) / create / use (activate for this conversation, with inline integrity check) / rebuild (clear-and-rebuild the current conversation in place, Ship of Theseus) / send-now (deliver ONE message to a trajectory RIGHT NOW — live: injected immediately; not live: cold-resumed, i.e. equivalent to a direct wake; returns a synchronous receipt) / send-later (schedule ONE message to any trajectory at a future instant — fire-and-forget: no receipt, no recall, no panel). create requires --desc <desc> [--goal] or --issue <ticket> (exactly one) plus --summary (≤20 chars). use requires --summary (≤20 chars) and may pass --force to switch away from the currently-bound trajectory. rebuild requires --summary (next-phase summary ≤20 chars) + optional --note (task focus for the rebuilt self). send-later requires target (S### or AGENT_SESSIONS dir name) + at (RFC3339 or +30m/+2h) + message. send-now requires target + message. It delivers IMMEDIATELY (never queued behind the current turn): a busy target is steered mid-turn so it reads the message in the CURRENT turn, and an idle target starts a turn right away. Its receipt means the message was injected into the target (it does NOT mean the target ran or replied). The summary is appended to the dsh session title (S###-YYYY-MM-DD-<summary>); the S### id and date stay server-derived.",
3129
3174
  parameters: {
3130
3175
  action: {
3131
3176
  type: "string",
@@ -3241,6 +3286,13 @@ function createTrajectoryTool(ctx) {
3241
3286
  context: active.context
3242
3287
  };
3243
3288
  if (advisory) out.advisory = advisory;
3289
+ if (dsh) {
3290
+ const selfId = String(dsh.header?.id ?? "");
3291
+ const superseded = supersedeOtherBindings(root, info?.dirName ?? targetDirName, selfId === "" ? /* @__PURE__ */ new Set() : /* @__PURE__ */ new Set([selfId]));
3292
+ if (superseded.length > 0) out.supersededBindings = superseded;
3293
+ const pruned = pruneMissingBindings(root, (id) => !hasSessionLogById(sessionsRootDir(), id));
3294
+ if (pruned.length > 0) out.prunedBindings = pruned;
3295
+ }
3244
3296
  return out;
3245
3297
  }
3246
3298
  case "rebuild": {
@@ -3295,8 +3347,8 @@ function createTrajectoryTool(ctx) {
3295
3347
  output: [
3296
3348
  "✓ 已即时投递",
3297
3349
  ` · ${res.detail}`,
3298
- " · 语义:消息**已进入目标队列**;目标若正在跑轮次,则在**轮次边界**生效(不打断当前轮)。",
3299
- " · ⚠️ 回执只到「已入队」——**不表示**目标已执行或已答复。判\"目标真的动了\"须看它自己的 SESSION.md(文件级判据)。"
3350
+ " · 语义:**不排队** —— 目标正在跑轮次则**当场注入当前轮**(steer),空闲则立即起一轮(followup)。",
3351
+ " · ⚠️ 回执只到「已注入 / 已起轮」——**不表示**目标已执行或已答复。判\"目标真的动了\"须看它自己的 SESSION.md(文件级判据)。"
3300
3352
  ].join("\n")
3301
3353
  };
3302
3354
  }
@@ -4049,12 +4101,12 @@ function syncSafeModeRestriction(agent, root) {
4049
4101
  const dispose = agent.ctx.tools.restrict({ deny: [...SAFE_MODE_DENY_TOOLS] });
4050
4102
  safeModeRestrictions.set(key, dispose);
4051
4103
  restrictDiag.lastKey = key;
4052
- restrictDiag.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
4104
+ restrictDiag.lastAttemptAt = isoLocal();
4053
4105
  restrictDiag.lastSuccess = true;
4054
4106
  restrictDiag.lastError = null;
4055
4107
  } catch (e) {
4056
4108
  restrictDiag.lastKey = key;
4057
- restrictDiag.lastAttemptAt = (/* @__PURE__ */ new Date()).toISOString();
4109
+ restrictDiag.lastAttemptAt = isoLocal();
4058
4110
  restrictDiag.lastSuccess = false;
4059
4111
  restrictDiag.lastError = e.message;
4060
4112
  console.error(`[serenity-hooks] restrict 失败 (key=${key}):`, e.message);
@@ -6394,7 +6446,7 @@ function getStatus(cwd, configPaths = DEFAULT_SERENITY_CONFIG_PATHS) {
6394
6446
  function setSafeMode(root, on) {
6395
6447
  const marker = resolve(root, SAFE_MODE_MARKER);
6396
6448
  if (on) {
6397
- if (!existsSync(marker)) writeFileSync(marker, (/* @__PURE__ */ new Date()).toISOString() + "\n", "utf-8");
6449
+ if (!existsSync(marker)) writeFileSync(marker, isoLocal() + "\n", "utf-8");
6398
6450
  } else rmSync(marker, { force: true });
6399
6451
  return { on: isSafeModeOn(root) };
6400
6452
  }
@@ -6458,7 +6510,7 @@ function saveImageToTmp(root, mediaType, data) {
6458
6510
  const dir = join(root, IMAGE_UPLOAD_DIR);
6459
6511
  mkdirSync(dir, { recursive: true });
6460
6512
  const ext = EXT_BY_MEDIA[mediaType] ?? "img";
6461
- const filename = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
6513
+ const filename = `${localFileStamp()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
6462
6514
  writeFileSync(join(dir, filename), bytes);
6463
6515
  return `${IMAGE_UPLOAD_DIR}/${filename}`;
6464
6516
  }
@@ -6486,7 +6538,7 @@ function saveFileToTmp(root, fileName, data) {
6486
6538
  const dir = join(root, FILE_UPLOAD_DIR);
6487
6539
  mkdirSync(dir, { recursive: true });
6488
6540
  const safeName = sanitizeFileName(fileName);
6489
- const filename = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
6541
+ const filename = `${localFileStamp()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
6490
6542
  writeFileSync(join(dir, filename), bytes);
6491
6543
  return `${FILE_UPLOAD_DIR}/${filename}`;
6492
6544
  }
@@ -6985,7 +7037,7 @@ function registerStatusApi(ctx, opts = {}) {
6985
7037
  const id = s.id ?? s.header?.id;
6986
7038
  if (id) liveIds.add(id);
6987
7039
  }
6988
- const { sessionsRootDir, collectEligibleSessions, performCleanup, cutoffDaysAgo } = await import("./session-cleanup-B6IHZgww.js");
7040
+ const { sessionsRootDir, collectEligibleSessions, performCleanup, cutoffDaysAgo } = await import("./session-cleanup-BtkSmry8.js").then((n) => n.n);
6989
7041
  const root = sessionsRootDir();
6990
7042
  if (req.method === "GET") {
6991
7043
  const { candidates } = performCleanup(root, cutoffDaysAgo(days), liveIds, { dryRun: true });
@@ -6997,7 +7049,7 @@ function registerStatusApi(ctx, opts = {}) {
6997
7049
  candidates: candidates.map((c) => ({
6998
7050
  id: c.id,
6999
7051
  project: c.project,
7000
- lastActive: new Date(c.lastActiveMs).toISOString()
7052
+ lastActive: isoLocal(c.lastActiveMs)
7001
7053
  }))
7002
7054
  });
7003
7055
  return;
@@ -8853,7 +8905,7 @@ function writeRebuildDiag(root, entry) {
8853
8905
  if (entry.event === "rebuilt") s.rebuiltCount += 1;
8854
8906
  if (entry.event === "ttl-dropped") s.droppedCount += 1;
8855
8907
  if (entry.event === "failed") s.failedCount += 1;
8856
- s.lastTs = (/* @__PURE__ */ new Date()).toISOString();
8908
+ s.lastTs = isoLocal();
8857
8909
  s.lastSessionId = entry.sessionId;
8858
8910
  s.lastEvent = entry.event;
8859
8911
  s.detail = entry.detail;
@@ -9029,7 +9081,7 @@ function registerRebuildTurnHook(ctx) {
9029
9081
  writeRebuildDiag(resolveSerenityRootFor(agent), {
9030
9082
  sessionId: id,
9031
9083
  event: "ttl-dropped",
9032
- detail: `queuedAt=${new Date(pending.queuedAt).toISOString()} older than ${PENDING_TTL_MS / 6e4}min TTL — turn did not end in time`
9084
+ detail: `queuedAt=${isoLocal(pending.queuedAt)} older than ${PENDING_TTL_MS / 6e4}min TTL — turn did not end in time`
9033
9085
  });
9034
9086
  return;
9035
9087
  }
@@ -14271,7 +14323,7 @@ function containerStatus(opts = {}) {
14271
14323
  */
14272
14324
  /** 人读渲染一行时钟状态("为何没有 tick" 的第一手判据) */
14273
14325
  function renderClock(label, s) {
14274
- const ts = (ms) => ms === null ? "(从未)" : new Date(ms).toISOString();
14326
+ const ts = (ms) => ms === null ? "(从未)" : isoLocal(ms);
14275
14327
  const parts = [
14276
14328
  `armed=${s.armed}`,
14277
14329
  `全局闸=${s.enabled}`,
@@ -1,3 +1,4 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
1
2
  import { join } from "node:path";
2
3
  import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
3
4
  //#region src/session-cleanup.ts
@@ -19,6 +20,16 @@ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
19
20
  *
20
21
  * 本模块纯逻辑(零 ctx 依赖):传入 sessionsRoot + liveIds,可单测。
21
22
  */
23
+ var session_cleanup_exports = /* @__PURE__ */ __exportAll({
24
+ collectEligibleSessions: () => collectEligibleSessions,
25
+ cutoffDaysAgo: () => cutoffDaysAgo,
26
+ findSessionLog: () => findSessionLog,
27
+ hasSessionLogById: () => hasSessionLogById,
28
+ performCleanup: () => performCleanup,
29
+ sessionLogArtifacts: () => sessionLogArtifacts,
30
+ sessionLogBytesById: () => sessionLogBytesById,
31
+ sessionsRootDir: () => sessionsRootDir
32
+ });
22
33
  /** DSH 会话 root:env DSH_HOME → ~/.dsh(config-ops globalConfigPath 同款推导) */
23
34
  function sessionsRootDir() {
24
35
  const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? "", ".dsh");
@@ -142,9 +153,61 @@ function performCleanup(root, cutoffMs, liveIds = /* @__PURE__ */ new Set(), opt
142
153
  function dirOf(logPath) {
143
154
  return logPath.slice(0, logPath.lastIndexOf("/")) || logPath;
144
155
  }
156
+ /**
157
+ * 某一 dsh 会话(目录名 / 会话 id)的**当前世代日志体积**(字节)。
158
+ *
159
+ * 为什么要它(S142 §0y「旧会话累积 ⇒ dsh 内存崩溃」,所有者令):
160
+ * · 宿主恢复/继续一条会话必须**重放全部事件**(`scanLog` → `materializeAppendBatch`)
161
+ * ⇒ **代价 ∝ 日志体积**;
162
+ * · 实测(`_tmp/measure-session-load.mjs`,照宿主 `scanZstdFrames` 复刻):
163
+ * 一条 **72.2 MB**(zstd)的日志 = 35 559 个 frame / 252.6 MB 文本 / 62 680 条事件,
164
+ * **光是读一遍峰值 RSS +1 175 MB**(探针什么都不保留);
165
+ * · 而宿主的解析缓存 `COLD_LOG_MEMO_MAX_ENTRIES = 2` 是**按条数限、不按字节限**的
166
+ * (`dsh-session-persistence-jsonl/lib/index.js:2175`)⇒ 两条巨日志 = 上限内。
167
+ * 🔴 **在本函数之前,ACC 侧没有任何地方知道"绑定的这条会话日志多大"——这个信号根本不存在。**
168
+ * 这是"靠 agent 自觉 rebuild 不够"的机制层原因(keeper 的 K 量的是**模型上下文**,压缩后回落;
169
+ * 而**日志文件只增不减** ⇒ rebuild 提醒永远不会因为"日志 72 MB"触发)。
170
+ *
171
+ * 只认会话目录(含任意世代日志);返回**最高世代**的日志(= 宿主 `findLog` 选中的当前世代)。
172
+ * 未命中 / 不可读 → null(非错误,调用方按"未知"处理)。
173
+ * @param root - DSH 会话 root({@link sessionsRootDir})
174
+ * @param sessionId - 会话目录名(`.bindings.json` 的键,含 `session-` 前缀)或裸 id
175
+ * @returns 字节数 + 日志绝对路径;未命中 → null
176
+ */
177
+ function sessionLogBytesById(root, sessionId) {
178
+ if (sessionId === "" || !existsSync(root)) return null;
179
+ const dirName = sessionId.startsWith("session-") ? sessionId : `session-${sessionId}`;
180
+ for (const project of readdirSync(root, { withFileTypes: true })) {
181
+ if (!project.isDirectory()) continue;
182
+ const log = findSessionLog(join(root, project.name, dirName));
183
+ if (log === null) continue;
184
+ try {
185
+ return {
186
+ bytes: statSync(log).size,
187
+ path: log
188
+ };
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+ /**
196
+ * **(c) 绑定一致性兜底**的判据:某会话的日志**是否仍在磁盘上**。
197
+ *
198
+ * 🔴 **fail-closed**:sessions root 本身不存在时**一律返回 true(当作"在")**——
199
+ * 否则换 `DSH_HOME` / 换机器会让"全部绑定看起来都悬空",进而误清整张绑定表。
200
+ * 只有"root 在、而这个会话目录确实不在"才算悬空。
201
+ * @param sessionRoot - DSH 会话 root({@link sessionsRootDir})
202
+ * @param sessionId - 会话目录名(`.bindings.json` 的键)或裸 id
203
+ */
204
+ function hasSessionLogById(sessionRoot, sessionId) {
205
+ if (!existsSync(sessionRoot)) return true;
206
+ return sessionLogBytesById(sessionRoot, sessionId) !== null;
207
+ }
145
208
  /** 便捷:默认 N 天前为 cutoff(供 API/调用方) */
146
209
  function cutoffDaysAgo(days, nowMs = Date.now()) {
147
210
  return nowMs - days * DAY_MS;
148
211
  }
149
212
  //#endregion
150
- export { cutoffDaysAgo, performCleanup, sessionsRootDir };
213
+ export { session_cleanup_exports as n, sessionsRootDir as r, hasSessionLogById as t };
@@ -73,6 +73,41 @@ export declare function performCleanup(root: string, cutoffMs: number, liveIds?:
73
73
  candidates: CandidateSession[];
74
74
  result: CleanupResult | null;
75
75
  };
76
+ /**
77
+ * 某一 dsh 会话(目录名 / 会话 id)的**当前世代日志体积**(字节)。
78
+ *
79
+ * 为什么要它(S142 §0y「旧会话累积 ⇒ dsh 内存崩溃」,所有者令):
80
+ * · 宿主恢复/继续一条会话必须**重放全部事件**(`scanLog` → `materializeAppendBatch`)
81
+ * ⇒ **代价 ∝ 日志体积**;
82
+ * · 实测(`_tmp/measure-session-load.mjs`,照宿主 `scanZstdFrames` 复刻):
83
+ * 一条 **72.2 MB**(zstd)的日志 = 35 559 个 frame / 252.6 MB 文本 / 62 680 条事件,
84
+ * **光是读一遍峰值 RSS +1 175 MB**(探针什么都不保留);
85
+ * · 而宿主的解析缓存 `COLD_LOG_MEMO_MAX_ENTRIES = 2` 是**按条数限、不按字节限**的
86
+ * (`dsh-session-persistence-jsonl/lib/index.js:2175`)⇒ 两条巨日志 = 上限内。
87
+ * 🔴 **在本函数之前,ACC 侧没有任何地方知道"绑定的这条会话日志多大"——这个信号根本不存在。**
88
+ * 这是"靠 agent 自觉 rebuild 不够"的机制层原因(keeper 的 K 量的是**模型上下文**,压缩后回落;
89
+ * 而**日志文件只增不减** ⇒ rebuild 提醒永远不会因为"日志 72 MB"触发)。
90
+ *
91
+ * 只认会话目录(含任意世代日志);返回**最高世代**的日志(= 宿主 `findLog` 选中的当前世代)。
92
+ * 未命中 / 不可读 → null(非错误,调用方按"未知"处理)。
93
+ * @param root - DSH 会话 root({@link sessionsRootDir})
94
+ * @param sessionId - 会话目录名(`.bindings.json` 的键,含 `session-` 前缀)或裸 id
95
+ * @returns 字节数 + 日志绝对路径;未命中 → null
96
+ */
97
+ export declare function sessionLogBytesById(root: string, sessionId: string): {
98
+ bytes: number;
99
+ path: string;
100
+ } | null;
101
+ /**
102
+ * **(c) 绑定一致性兜底**的判据:某会话的日志**是否仍在磁盘上**。
103
+ *
104
+ * 🔴 **fail-closed**:sessions root 本身不存在时**一律返回 true(当作"在")**——
105
+ * 否则换 `DSH_HOME` / 换机器会让"全部绑定看起来都悬空",进而误清整张绑定表。
106
+ * 只有"root 在、而这个会话目录确实不在"才算悬空。
107
+ * @param sessionRoot - DSH 会话 root({@link sessionsRootDir})
108
+ * @param sessionId - 会话目录名(`.bindings.json` 的键)或裸 id
109
+ */
110
+ export declare function hasSessionLogById(sessionRoot: string, sessionId: string): boolean;
76
111
  /** 便捷:默认 N 天前为 cutoff(供 API/调用方) */
77
112
  export declare function cutoffDaysAgo(days: number, nowMs?: number): number;
78
113
  export {};
package/lib/time.d.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * time.ts — ACC **时间呈现的单一真相源**(S142 2026-09-17,所有者令:「统一 ACC 所有使用时间的地方使用当地时区」)
3
+ *
4
+ * ── 存在的理由:**把"时刻"与"呈现"分开**(R↓)
5
+ *
6
+ * · **时刻(instant)** = epoch 毫秒(`Date.now()`)—— **与时区无关**。
7
+ * 比较 / 差值 / TTL / 调度 / TOTP 一律照旧,**不得"本地化"**:
8
+ * 把时刻换成"当地钟面"会让它**不可比**(跨偏移、跨夏令时都会错)。
9
+ * · **呈现(rendition)** = 给人看、或存给人看的字符串 —— **一律当地时区 + 显式偏移**,
10
+ * 如 `2026-09-17T17:20:00.123+08:00`。
11
+ * ⚠️ **偏移不能省**(这是本模块最容易被"简化"掉的一点):省了就丢掉"这是哪一刻",
12
+ * 而且**跨格式的字符串排序会错**(`...T09:00:00Z` 与 `...T17:00:00+08:00` 是同一刻,
13
+ * 字典序却相反)。带偏移的 RFC3339 **同时**满足"当地钟面"与"可解析、可比较"。
14
+ * · **当地** = 运行机器的本地时区(node 在 `Asia/Shanghai` 即 `+08:00`)—— **不硬编码时区名**,
15
+ * 否则换机器/换地区就错。偏移**按被格式化的那个时刻求**(`getTimezoneOffset` 随夏令时变化)。
16
+ *
17
+ * ── 用法边界(本模块**只**做呈现;不做时刻运算)
18
+ * ✅ `isoLocal()` / `localDate()` / `localDateTime()` / `localHuman()` / `localFileStamp()`
19
+ * ❌ 不要用它们做排序/比较/差值 —— 那些用 `Date.now()` / `Date.parse()` / epoch 运算。
20
+ *
21
+ * 出处:所有者 2026-09-17 令(统一当地时区 + 注入消息带当前时间);设计判据见 S142 §0r。
22
+ */
23
+ /**
24
+ * 该**时刻**的 UTC 偏移(`+08:00` / `-05:00`)。
25
+ * ⚠️ 必须传入要格式化的那个 date —— 偏移随夏令时变化,不能"取当前的偏移去格式化另一个时刻"。
26
+ */
27
+ export declare function tzOffset(date?: Date): string;
28
+ /**
29
+ * **RFC3339 当地时区**(带毫秒与偏移):`2026-09-17T17:20:00.123+08:00`。
30
+ * 存储与会话间交换的**默认呈现**(可被 `Date.parse` 原样解析回同一时刻)。
31
+ */
32
+ export declare function isoLocal(value?: Date | number): string;
33
+ /** 当地日期 `YYYY-MM-DD`(目录名/日期前缀) */
34
+ export declare function localDate(value?: Date | number): string;
35
+ /** 当地 `YYYY-MM-DD HH:mm:ss`(人读;**不带偏移**,只用于已经明确"是本地时间"的展示位) */
36
+ export declare function localDateTime(value?: Date | number): string;
37
+ /** 当地 `YYYY-MM-DD HH:mm`(人读,到分钟) */
38
+ export declare function localDateTimeMinutes(value?: Date | number): string;
39
+ /**
40
+ * **人读 + 偏移**:`2026-09-17 17:20:00 +08:00`。
41
+ * 给"注入给 agent 的文本"用 —— 既要一眼看懂当地钟面,又不丢"这是哪一刻"。
42
+ */
43
+ export declare function localHuman(value?: Date | number): string;
44
+ /**
45
+ * 文件名/ID 用的紧凑当地戳:`2026-09-17T172000`(可排序、无冒号/点,跨平台安全)。
46
+ * 与 CCC 的文件命名底线一致(`<ISO8601 本地>-<短随机>`)。
47
+ */
48
+ export declare function localFileStamp(value?: Date | number): string;
49
+ /** 唤醒条目 id 的当地戳部分:`YYYYMMDD-HHmm`(`w-<stamp>-<hex>`) */
50
+ export declare function localIdStamp(value?: Date | number): string;
@@ -1,6 +1,92 @@
1
1
  import { r as cccRootForCwd } from "./ccc-roots-Bd_SjEs7.js";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
4
+ //#region src/time.ts
5
+ /**
6
+ * time.ts — ACC **时间呈现的单一真相源**(S142 2026-09-17,所有者令:「统一 ACC 所有使用时间的地方使用当地时区」)
7
+ *
8
+ * ── 存在的理由:**把"时刻"与"呈现"分开**(R↓)
9
+ *
10
+ * · **时刻(instant)** = epoch 毫秒(`Date.now()`)—— **与时区无关**。
11
+ * 比较 / 差值 / TTL / 调度 / TOTP 一律照旧,**不得"本地化"**:
12
+ * 把时刻换成"当地钟面"会让它**不可比**(跨偏移、跨夏令时都会错)。
13
+ * · **呈现(rendition)** = 给人看、或存给人看的字符串 —— **一律当地时区 + 显式偏移**,
14
+ * 如 `2026-09-17T17:20:00.123+08:00`。
15
+ * ⚠️ **偏移不能省**(这是本模块最容易被"简化"掉的一点):省了就丢掉"这是哪一刻",
16
+ * 而且**跨格式的字符串排序会错**(`...T09:00:00Z` 与 `...T17:00:00+08:00` 是同一刻,
17
+ * 字典序却相反)。带偏移的 RFC3339 **同时**满足"当地钟面"与"可解析、可比较"。
18
+ * · **当地** = 运行机器的本地时区(node 在 `Asia/Shanghai` 即 `+08:00`)—— **不硬编码时区名**,
19
+ * 否则换机器/换地区就错。偏移**按被格式化的那个时刻求**(`getTimezoneOffset` 随夏令时变化)。
20
+ *
21
+ * ── 用法边界(本模块**只**做呈现;不做时刻运算)
22
+ * ✅ `isoLocal()` / `localDate()` / `localDateTime()` / `localHuman()` / `localFileStamp()`
23
+ * ❌ 不要用它们做排序/比较/差值 —— 那些用 `Date.now()` / `Date.parse()` / epoch 运算。
24
+ *
25
+ * 出处:所有者 2026-09-17 令(统一当地时区 + 注入消息带当前时间);设计判据见 S142 §0r。
26
+ */
27
+ /** 两位补零(超两位按原样,用于毫秒 3 位) */
28
+ function pad(n, width = 2) {
29
+ return String(n).padStart(width, "0");
30
+ }
31
+ /**
32
+ * 该**时刻**的 UTC 偏移(`+08:00` / `-05:00`)。
33
+ * ⚠️ 必须传入要格式化的那个 date —— 偏移随夏令时变化,不能"取当前的偏移去格式化另一个时刻"。
34
+ */
35
+ function tzOffset(date = /* @__PURE__ */ new Date()) {
36
+ const minutesEast = -date.getTimezoneOffset();
37
+ const sign = minutesEast >= 0 ? "+" : "-";
38
+ const abs = Math.abs(minutesEast);
39
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
40
+ }
41
+ /** 归一成 Date(接受 Date 或 epoch 毫秒) */
42
+ function toDate(value = Date.now()) {
43
+ return value instanceof Date ? value : new Date(value);
44
+ }
45
+ /**
46
+ * **RFC3339 当地时区**(带毫秒与偏移):`2026-09-17T17:20:00.123+08:00`。
47
+ * 存储与会话间交换的**默认呈现**(可被 `Date.parse` 原样解析回同一时刻)。
48
+ */
49
+ function isoLocal(value = Date.now()) {
50
+ const d = toDate(value);
51
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` + tzOffset(d);
52
+ }
53
+ /** 当地日期 `YYYY-MM-DD`(目录名/日期前缀) */
54
+ function localDate(value = Date.now()) {
55
+ const d = toDate(value);
56
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
57
+ }
58
+ /** 当地 `YYYY-MM-DD HH:mm:ss`(人读;**不带偏移**,只用于已经明确"是本地时间"的展示位) */
59
+ function localDateTime(value = Date.now()) {
60
+ const d = toDate(value);
61
+ return `${localDate(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
62
+ }
63
+ /** 当地 `YYYY-MM-DD HH:mm`(人读,到分钟) */
64
+ function localDateTimeMinutes(value = Date.now()) {
65
+ const d = toDate(value);
66
+ return `${localDate(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
67
+ }
68
+ /**
69
+ * **人读 + 偏移**:`2026-09-17 17:20:00 +08:00`。
70
+ * 给"注入给 agent 的文本"用 —— 既要一眼看懂当地钟面,又不丢"这是哪一刻"。
71
+ */
72
+ function localHuman(value = Date.now()) {
73
+ const d = toDate(value);
74
+ return `${localDateTime(d)} ${tzOffset(d)}`;
75
+ }
76
+ /**
77
+ * 文件名/ID 用的紧凑当地戳:`2026-09-17T172000`(可排序、无冒号/点,跨平台安全)。
78
+ * 与 CCC 的文件命名底线一致(`<ISO8601 本地>-<短随机>`)。
79
+ */
80
+ function localFileStamp(value = Date.now()) {
81
+ const d = toDate(value);
82
+ return `${localDate(d)}T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
83
+ }
84
+ /** 唤醒条目 id 的当地戳部分:`YYYYMMDD-HHmm`(`w-<stamp>-<hex>`) */
85
+ function localIdStamp(value = Date.now()) {
86
+ const d = toDate(value);
87
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
88
+ }
89
+ //#endregion
4
90
  //#region src/trajectory-ops.ts
5
91
  /**
6
92
  * trajectory-ops.ts — 轨迹工具纯操作层(零 DSH 依赖,可独立单测)
@@ -142,7 +228,7 @@ function showSession(root, key) {
142
228
  }
143
229
  /** 生成 SESSION.md 模板(对齐 osp:goal 写入目标段,时间戳 YYYY-MM-DD HH:mm) */
144
230
  function sessionMdTemplate(title, id, goal, now) {
145
- const ts = now.toISOString().slice(0, 16).replace("T", " ");
231
+ const ts = localDateTimeMinutes(now);
146
232
  return `# SESSION: ${title}\n- ID: ${id}\n\n## 目标\n${goal ?? "(待补充)"}\n\n## 状态\n- [ ] 进行中\n\n## 关键决策\n| # | 决策 | 理由 |\n|---|------|------|\n| 1 | | |\n\n## 进度记录\n- ${ts} — 创建\n\n## 产出物\n- \n\n## 未解决的问题\n- \n`;
147
233
  }
148
234
  /** create 子命令(对齐 osp createSession:--desc/--issue 二选一 + dry-run + 长度限制) */
@@ -156,7 +242,7 @@ function createSession(opts) {
156
242
  const { root, desc, issue, goal, dryRun } = opts;
157
243
  const sessionsDir = sessionsRoot(root);
158
244
  const now = /* @__PURE__ */ new Date();
159
- const datePrefix = now.toISOString().slice(0, 10);
245
+ const datePrefix = localDate(now);
160
246
  if (!desc && !issue) throw new Error("create requires either --desc or --issue");
161
247
  if (desc && issue) throw new Error("--desc and --issue are mutually exclusive");
162
248
  if (issue) {
@@ -558,6 +644,7 @@ function listBoundSessionIds(root, dirName) {
558
644
  for (const [id, raw] of Object.entries(file.sessions)) {
559
645
  const rec = asBoundRecord(raw);
560
646
  if (!rec || rec.dirName !== dirName) continue;
647
+ if (rec.supersededAt !== void 0) continue;
561
648
  hits.push({
562
649
  id,
563
650
  at: typeof rec.at === "number" ? rec.at : 0
@@ -566,6 +653,82 @@ function listBoundSessionIds(root, dirName) {
566
653
  return hits.sort((a, b) => b.at - a.at).map((h) => h.id);
567
654
  }
568
655
  /**
656
+ * **(b) 同一 trajectory 多 live 会话的机械守卫**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
657
+ *
658
+ * 把绑定到 `dirName` 的**其它**会话 id 标记为 `supersededAt` —— **保留记录**(沿革/正向查询不丢),
659
+ * 只切断"**唤醒还会找到它**"这条路。语义上即"解绑",且**不动那个会话本身**(它仍可被人工使用)。
660
+ *
661
+ * 为什么必须有机制(R↓):`.bindings.json` 的语义是"latest-wins 覆盖**本条会话记录**",
662
+ * 对**同一 trajectory 的其它会话**没有任何约束 ⇒ 多代会话自然堆积(实测 S142 4 代 / S185 2 代),
663
+ * 而 `acquireWakeAgent` 是"遍历全部绑定 id、任一条 live 就用它"——**两条 live 都能被选中**。
664
+ *
665
+ * @param root CCC 根
666
+ * @param dirName 目标 trajectory 目录名
667
+ * @param keepIds 保留(不标记)的会话 id 集合——通常是"当前正在 use 的这条"
668
+ * @returns **实际被标记**的会话 id(升序稳定;供调用方如实报告,未标记任何一条 → `[]`)
669
+ */
670
+ function supersedeOtherBindings(root, dirName, keepIds) {
671
+ const path = join(root, BINDINGS_REL_PATH);
672
+ const file = readBindingsFile(path);
673
+ const marked = [];
674
+ const at = Date.now();
675
+ for (const [id, raw] of Object.entries(file.sessions)) {
676
+ const rec = asBoundRecord(raw);
677
+ if (!rec || rec.dirName !== dirName) continue;
678
+ if (keepIds.has(id) || rec.supersededAt !== void 0) continue;
679
+ rec.supersededAt = at;
680
+ file.sessions[id] = rec;
681
+ marked.push(id);
682
+ }
683
+ if (marked.length === 0) return [];
684
+ try {
685
+ writeBindingsFile(path, file);
686
+ } catch {
687
+ return [];
688
+ }
689
+ return marked.sort();
690
+ }
691
+ /**
692
+ * **(c) 绑定一致性兜底**(S142 §0y,所有者 2026-09-18 裁「c 也算个兜底」):
693
+ * 删除指向**已不存在**的 dsh 会话的绑定记录。
694
+ *
695
+ * 为什么需要:`.bindings.json` **只增不清**,而 DSH 会话目录会被
696
+ * `session-cleanup`(本仓既有机制)物理删除 ⇒ 表里必然残留**悬空 id**。
697
+ * 悬空记录本身无害,但它会**污染反向索引**(`listBoundSessionIds` 是唤醒选人的唯一入口),
698
+ * 让"这条轨迹到底还有没有可用会话"变得不可判。
699
+ *
700
+ * 🔴 **安全性由调用方的判据负责**(本函数是纯函数,不认识文件系统):
701
+ * `isMissing` 必须是 **fail-closed** 的(读不到 ⇒ 返回 false = "不当作缺失"),
702
+ * 见 `hasSessionLogById`。判据写错会**清掉整张表**,故此处刻意不提供默认实现。
703
+ *
704
+ * @param root CCC 根
705
+ * @param isMissing 判据:该会话 id 是否**确认**已不存在(false = 保留)
706
+ * @returns 被删除的会话 id(升序稳定;未删任何一条 → `[]`)
707
+ */
708
+ function pruneMissingBindings(root, isMissing) {
709
+ const path = join(root, BINDINGS_REL_PATH);
710
+ const file = readBindingsFile(path);
711
+ const removed = [];
712
+ for (const id of Object.keys(file.sessions)) {
713
+ let missing;
714
+ try {
715
+ missing = isMissing(id);
716
+ } catch {
717
+ continue;
718
+ }
719
+ if (!missing) continue;
720
+ delete file.sessions[id];
721
+ removed.push(id);
722
+ }
723
+ if (removed.length === 0) return [];
724
+ try {
725
+ writeBindingsFile(path, file);
726
+ } catch {
727
+ return [];
728
+ }
729
+ return removed.sort();
730
+ }
731
+ /**
569
732
  * 写入绑定(latest-wins 覆盖该会话记录)。
570
733
  *
571
734
  * 绑定持久化尽力而为:无法定位 CCC 根 / 无会话 id / 写盘失败 → 返回 false,不阻断主流程。
@@ -594,4 +757,4 @@ function appendBound(session, action, rec) {
594
757
  }
595
758
  }
596
759
  //#endregion
597
- export { sessionsRoot as _, DEFAULT_SESSION_SCOPE as a, summarize as b, createSession as c, getActiveSessionInfo as d, listSessions as f, sessionEvents as g, resolveSessionByTitle as h, resolveSessionTrajectoryLabel as i, extractSessionMdPathFromText as l, readActiveSessionMd as m, listBoundSessionIds as n, TRAJECTORY_ACTIONS as o, parseSessionContextFromEvents as p, readLastBound as r, clearActiveSessionInfo as s, appendBound as t, findSession as u, setActiveSessionInfo as v, useSession as x, showSession as y };
760
+ export { useSession as C, localIdStamp as D, localHuman as E, summarize as S, localFileStamp as T, resolveSessionByTitle as _, resolveSessionTrajectoryLabel as a, setActiveSessionInfo as b, TRAJECTORY_ACTIONS as c, extractSessionMdPathFromText as d, findSession as f, readActiveSessionMd as g, parseSessionContextFromEvents as h, readLastBound as i, clearActiveSessionInfo as l, listSessions as m, listBoundSessionIds as n, supersedeOtherBindings as o, getActiveSessionInfo as p, pruneMissingBindings as r, DEFAULT_SESSION_SCOPE as s, appendBound as t, createSession as u, sessionEvents as v, isoLocal as w, showSession as x, sessionsRoot as y };
@@ -31,6 +31,17 @@ interface SessionBoundRecord {
31
31
  action: SessionBoundAction;
32
32
  at: number;
33
33
  note?: string;
34
+ /**
35
+ * **(b) 已被取代标记**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
36
+ *
37
+ * 语义:这条绑定**仍然存在**(沿革不丢、正向 `readLastBound` 仍能查到"我曾属于哪条轨迹"),
38
+ * 但**不再作为唤醒候选**({@link listBoundSessionIds} 过滤掉)——这正是"解绑"的机械形态。
39
+ *
40
+ * 为什么需要(实证):`.bindings.json` 只增不清——实测 S142 名下挂 **4 代**、S185 挂 **2 代**,
41
+ * 同一 trajectory 因此可以有**两条 live 会话**:各持一份完整上下文(内存翻倍)
42
+ * 且**都在写同一批文件**(双写者)。
43
+ */
44
+ supersededAt?: number;
34
45
  }
35
46
  /** 绑定文件相对 CCC 根的路径 */
36
47
  export declare const BINDINGS_REL_PATH = "AGENT_SESSIONS/.bindings.json";
@@ -73,6 +84,40 @@ export declare function resolveSessionTrajectoryLabel(session: unknown, scope: s
73
84
  * @returns 会话 id 列表(最新绑定在前);无绑定 → `[]`
74
85
  */
75
86
  export declare function listBoundSessionIds(root: string, dirName: string): string[];
87
+ /**
88
+ * **(b) 同一 trajectory 多 live 会话的机械守卫**(S142 §0y,所有者 2026-09-18 裁「b 做」)。
89
+ *
90
+ * 把绑定到 `dirName` 的**其它**会话 id 标记为 `supersededAt` —— **保留记录**(沿革/正向查询不丢),
91
+ * 只切断"**唤醒还会找到它**"这条路。语义上即"解绑",且**不动那个会话本身**(它仍可被人工使用)。
92
+ *
93
+ * 为什么必须有机制(R↓):`.bindings.json` 的语义是"latest-wins 覆盖**本条会话记录**",
94
+ * 对**同一 trajectory 的其它会话**没有任何约束 ⇒ 多代会话自然堆积(实测 S142 4 代 / S185 2 代),
95
+ * 而 `acquireWakeAgent` 是"遍历全部绑定 id、任一条 live 就用它"——**两条 live 都能被选中**。
96
+ *
97
+ * @param root CCC 根
98
+ * @param dirName 目标 trajectory 目录名
99
+ * @param keepIds 保留(不标记)的会话 id 集合——通常是"当前正在 use 的这条"
100
+ * @returns **实际被标记**的会话 id(升序稳定;供调用方如实报告,未标记任何一条 → `[]`)
101
+ */
102
+ export declare function supersedeOtherBindings(root: string, dirName: string, keepIds: ReadonlySet<string>): string[];
103
+ /**
104
+ * **(c) 绑定一致性兜底**(S142 §0y,所有者 2026-09-18 裁「c 也算个兜底」):
105
+ * 删除指向**已不存在**的 dsh 会话的绑定记录。
106
+ *
107
+ * 为什么需要:`.bindings.json` **只增不清**,而 DSH 会话目录会被
108
+ * `session-cleanup`(本仓既有机制)物理删除 ⇒ 表里必然残留**悬空 id**。
109
+ * 悬空记录本身无害,但它会**污染反向索引**(`listBoundSessionIds` 是唤醒选人的唯一入口),
110
+ * 让"这条轨迹到底还有没有可用会话"变得不可判。
111
+ *
112
+ * 🔴 **安全性由调用方的判据负责**(本函数是纯函数,不认识文件系统):
113
+ * `isMissing` 必须是 **fail-closed** 的(读不到 ⇒ 返回 false = "不当作缺失"),
114
+ * 见 `hasSessionLogById`。判据写错会**清掉整张表**,故此处刻意不提供默认实现。
115
+ *
116
+ * @param root CCC 根
117
+ * @param isMissing 判据:该会话 id 是否**确认**已不存在(false = 保留)
118
+ * @returns 被删除的会话 id(升序稳定;未删任何一条 → `[]`)
119
+ */
120
+ export declare function pruneMissingBindings(root: string, isMissing: (sessionId: string) => boolean): string[];
76
121
  /**
77
122
  * 写入绑定(latest-wins 覆盖该会话记录)。
78
123
  *
@@ -22,11 +22,11 @@ export declare const WAKE_CATCH_UP_MS: number;
22
22
  type WakeState = 'pending' | 'delivered' | 'missed' | 'cancelled';
23
23
  /** 一条唤醒条目 */
24
24
  export interface WakeEntry {
25
- /** 稳定 id(不复用;`w-<UTC 到分钟>-<4 hex>`) */
25
+ /** 稳定 id(不复用;`w-<当地到分钟>-<4 hex>`) */
26
26
  id: string;
27
27
  /** 目标 trajectory:`S###` 或完整 AGENT_SESSIONS 目录名(编码无关,与绑定锚一致) */
28
28
  target: string;
29
- /** 绝对时刻(UTC RFC3339,如 `2026-09-14T01:00:00.000Z`) */
29
+ /** 绝对时刻(当地 RFC3339 带偏移,如 `2026-09-14T09:00:00.000+08:00`) */
30
30
  at: string;
31
31
  /** 唤醒 message(投递给目标 trajectory 的正文) */
32
32
  message: string;
@@ -34,13 +34,13 @@ export interface WakeEntry {
34
34
  state: WakeState;
35
35
  /** 发起者(轨迹标识,如 `S142`;可见性/审计用) */
36
36
  createdBy: string;
37
- /** 登记时刻(UTC RFC3339 */
37
+ /** 登记时刻(当地 RFC3339 带偏移) */
38
38
  createdAt: string;
39
39
  /** 已尝试投递次数 */
40
40
  attempts: number;
41
41
  /** 最近一次投递结果(人读;失败必须留痕——禁止静默) */
42
42
  lastResult: string | null;
43
- /** 投递成功时刻(UTC RFC3339 */
43
+ /** 投递成功时刻(当地 RFC3339 带偏移) */
44
44
  deliveredAt: string | null;
45
45
  }
46
46
  /** 注册表文件形态 */
@@ -59,7 +59,7 @@ export declare function loadWakeRegistry(root: string): {
59
59
  registry: WakeRegistry;
60
60
  error: string | null;
61
61
  };
62
- /** 条目 id:`w-<UTC 到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
62
+ /** 条目 id:`w-<当地到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
63
63
  export declare function newWakeId(nowMs: number, rand?: () => number): string;
64
64
  /**
65
65
  * 解析唤醒时刻。接受两种写法(设计文档 §7):
@@ -1,6 +1,6 @@
1
1
  import { r as hostService, t as hostAgents } from "./access-fiehjxV6.js";
2
2
  import { o as listCccs } from "./ccc-roots-Bd_SjEs7.js";
3
- import { _ as sessionsRoot, n as listBoundSessionIds, u as findSession } from "./trajectory-bound-CApZRbvx.js";
3
+ import { D as localIdStamp, E as localHuman, f as findSession, n as listBoundSessionIds, w as isoLocal, y as sessionsRoot } from "./trajectory-bound-B9o_4CMF.js";
4
4
  import { t as readSimpleSettings } from "./settings-section-DMDUoQum.js";
5
5
  import { basename, dirname, join } from "node:path";
6
6
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -115,10 +115,9 @@ function saveWakeRegistry(root, registry) {
115
115
  };
116
116
  }
117
117
  }
118
- /** 条目 id:`w-<UTC 到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
118
+ /** 条目 id:`w-<当地到分钟>-<4 hex>`(可读 + 低碰撞;不复用由"物理移除"保证) */
119
119
  function newWakeId(nowMs, rand = Math.random) {
120
- const iso = new Date(nowMs).toISOString();
121
- return `w-${`${iso.slice(0, 10).replace(/-/g, "")}-${iso.slice(11, 16).replace(":", "")}`}-${Math.floor(rand() * 65536).toString(16).padStart(4, "0")}`;
120
+ return `w-${localIdStamp(nowMs)}-${Math.floor(rand() * 65536).toString(16).padStart(4, "0")}`;
122
121
  }
123
122
  /**
124
123
  * 解析唤醒时刻。接受两种写法(设计文档 §7):
@@ -187,11 +186,11 @@ function addWake(root, input) {
187
186
  const entry = {
188
187
  id: newWakeId(input.nowMs),
189
188
  target,
190
- at: new Date(parsed.ms).toISOString(),
189
+ at: isoLocal(parsed.ms),
191
190
  message,
192
191
  state: "pending",
193
192
  createdBy: input.createdBy,
194
- createdAt: new Date(input.nowMs).toISOString(),
193
+ createdAt: isoLocal(input.nowMs),
195
194
  attempts: 0,
196
195
  lastResult: null,
197
196
  deliveredAt: null
@@ -573,6 +572,7 @@ function wakeSchedulerState() {
573
572
  * @param root CCC 根
574
573
  * @param dirName 目标 trajectory 目录名
575
574
  * @returns 命中返回 agent 与取得方式(how 进 lastResult,便于诊断);未命中返回原因
575
+ * (原因带 `notReady` = **环境未就绪**而非投递失败,调用方**不得**据此记一次失败)
576
576
  */
577
577
  async function acquireWakeAgent(ctx, root, dirName) {
578
578
  const agents = hostAgents(ctx);
@@ -587,7 +587,10 @@ async function acquireWakeAgent(ctx, root, dirName) {
587
587
  if (ids.length > 0) {
588
588
  const sc = hostService(ctx, "sessionController");
589
589
  const resolved = sc?.resolveAgent;
590
- if (typeof resolved !== "function") return { error: `目标会话未加载且 sessionController 不可用(headless profile?)——绑定 id: ${ids[0]}` };
590
+ if (typeof resolved !== "function") return {
591
+ error: `宿主尚未就绪:目标会话未加载,且 sessionController 服务此刻不可用(重启后的启动窗口,或本进程确实无该服务)——绑定 id: ${ids[0]}`,
592
+ notReady: true
593
+ };
591
594
  const id = ids[0];
592
595
  try {
593
596
  const out = await resolved.call(sc, id);
@@ -606,6 +609,7 @@ async function acquireWakeAgent(ctx, root, dirName) {
606
609
  function buildWakeText(entry, target) {
607
610
  return [
608
611
  `[trajectory 唤醒] 到点(登记于 ${entry.createdAt},发起者 ${entry.createdBy || "未知"},id ${entry.id})。`,
612
+ `当前时间:${localHuman()}(当地时区)。`,
609
613
  "",
610
614
  `身份锚定:继续 ${target.dirName} 的 trajectory(SESSION.md: ${target.mdPath})。`,
611
615
  "",
@@ -631,7 +635,8 @@ async function deliverWake(ctx, root, entry) {
631
635
  const acquired = await acquireWakeAgent(ctx, root, target.dirName);
632
636
  if ("error" in acquired) return {
633
637
  ok: false,
634
- detail: acquired.error
638
+ detail: acquired.error,
639
+ notReady: acquired.notReady === true
635
640
  };
636
641
  try {
637
642
  acquired.agent.followup(createUserMessage({
@@ -654,29 +659,33 @@ async function deliverWake(ctx, root, entry) {
654
659
  }
655
660
  /**
656
661
  * 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
657
- * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"
658
- * 并写明"消息已入队、若此前在跑轮次则本轮结束后生效"(防目标误判时序)。
662
+ * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"
663
+ *
664
+ * ⚠️ **v1.39.2 起"已入队"这句话必须删掉**(S142 §0r,所有者令「不排队、直接即时注入」):
665
+ * 旧文案写"消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效"——
666
+ * 那是**旧的 followup 语义**;现在在跑的目标走 `steer`(当场注入当前轮),
667
+ * 留着这句话就是**注入一条假陈述**(比没有更坏:目标会误判自己看到它的时机)。
659
668
  * @param target 目标轨迹(目录名 + SESSION.md)
660
669
  * @param message 递送正文
661
670
  * @param sender 发起者轨迹标识(供目标与人类识别来源)
662
671
  */
663
672
  function buildSendText(target, message, sender) {
664
673
  return [
665
- `[即时消息] 来自 ${sender || "未知轨迹"}。`,
674
+ `[即时消息] 来自 ${sender || "未知轨迹"}。当前时间:${localHuman()}(当地时区)。`,
666
675
  "",
667
676
  `身份锚定:继续 ${target.dirName} 的 trajectory(SESSION.md: ${target.mdPath})。`,
668
677
  "",
669
678
  "消息:",
670
679
  message,
671
680
  "",
672
- "说明:这是**即时投递**(不是到点唤醒)——消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效。处理完照常把进展写入 SESSION.md。"
681
+ "说明:这是**即时投递**(不是到点唤醒)——它**不排队**:目标正在跑轮次就**当场注入当前轮**,目标空闲则立即起一轮。处理完照常把进展写入 SESSION.md。"
673
682
  ].join("\n");
674
683
  }
675
684
  /**
676
685
  * 即时投递(`container_trajectory send-now`)——与 `send-later`(预约)**共用取用通路**(`acquireWakeAgent`),
677
686
  * 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
678
687
  * · `send-later`(原 `wake-later`):未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
679
- * · `send-now`(原 `send-message`):**此刻**把一条 message 递进目标队列 → **同步回执** → **不落注册表**
688
+ * · `send-now`(原 `send-message`):**此刻**把一条 message 递给目标 → **同步回执** → **不落注册表**
680
689
  *
681
690
  * **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
682
691
  * ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
@@ -684,10 +693,20 @@ function buildSendText(target, message, sender) {
684
693
  * 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
685
694
  * 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
686
695
  *
687
- * ⚠️ **承诺边界(不许含糊)**:回执只到「**已入队**」(`followup` 未抛错),
688
- * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。目标若正在跑轮次,
689
- * 消息**排在轮次边界**(**不打断当前轮**)。判"目标真的动了"必须用**文件级判据**
690
- * (它自己的 SESSION.md),**不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
696
+ * ⚠️ **承诺边界(不许含糊)**:回执只到「**已注入 / 已起轮**」(宿主调用未抛错),
697
+ * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。
698
+ * "目标真的动了"必须用**文件级判据**(它自己的 SESSION.md),
699
+ * **不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
700
+ *
701
+ * 🔴 **v1.39.2 语义变更(S142 §0r,所有者 2026-09-17 令:「希望 send message 时不排队,
702
+ * 可以直接作为提示词即时注入进去」)**:
703
+ * 旧实现一律 `followup`(宿主注释:*Queue an ordinary follow-up turn*)⇒ 目标**正在跑轮次**时
704
+ * 消息**排在轮次边界**(即旧文案的"不打断当前轮")。现按宿主 `agent.status` 分流:
705
+ * · `status === 'idle'` ⇒ `followup`(宿主**同步**把 status 置 running ⇒ **立即起轮**,本就不排队)
706
+ * · 否则(在跑) ⇒ **`steer`** —— **当场注入当前轮**,这才是"不排队"的关键路径
707
+ * ⚠️ **fail-safe**:`steer` 抛错 ⇒ **退回 `followup`**:**宁可排队,不可静默丢消息**。
708
+ * ⚠️ **只改 `send-now`**:`send-later`({@link deliverWake})**保持 `followup`** ——
709
+ * 预约的本职是"到点唤起",且在跑时排队不打断是它被实证验收过的行为,不顺手动它。
691
710
  *
692
711
  * **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
693
712
  * 工具只该知道"有这个动作"。这样 `trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
@@ -711,24 +730,48 @@ async function sendToTrajectory(ctx, root, target, message, sender) {
711
730
  ok: false,
712
731
  detail: acquired.error
713
732
  };
733
+ const agent = acquired.agent;
734
+ const make = () => createUserMessage({
735
+ content: [{
736
+ type: "text",
737
+ text: buildSendText(t, message, sender)
738
+ }],
739
+ source: PLUGIN_SOURCE
740
+ });
741
+ if (agent.status === "idle") {
742
+ try {
743
+ agent.followup(make());
744
+ } catch (err) {
745
+ return {
746
+ ok: false,
747
+ detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
748
+ };
749
+ }
750
+ return {
751
+ ok: true,
752
+ detail: `已即时投递 ${t.dirName}(${acquired.how};目标空闲 ⇒ followup 立即起轮)`
753
+ };
754
+ }
714
755
  try {
715
- acquired.agent.followup(createUserMessage({
716
- content: [{
717
- type: "text",
718
- text: buildSendText(t, message, sender)
719
- }],
720
- source: PLUGIN_SOURCE
721
- }));
756
+ agent.steer(make());
757
+ return {
758
+ ok: true,
759
+ detail: `已即时注入 ${t.dirName}(${acquired.how};目标在跑 steer 当场注入当前轮)`
760
+ };
722
761
  } catch (err) {
762
+ try {
763
+ agent.followup(make());
764
+ } catch (err2) {
765
+ return {
766
+ ok: false,
767
+ detail: `投递失败(${acquired.how}): steer 与 followup 均失败: ${String(err?.message ?? err)} / ${String(err2?.message ?? err2)}`
768
+ };
769
+ }
723
770
  return {
724
- ok: false,
725
- detail: `投递失败(${acquired.how}): ${String(err?.message ?? err)}`
771
+ ok: true,
772
+ detail: `已投递 ${t.dirName}(${acquired.how};steer 不可用 ⇒ 退回 followup): ${String(err?.message ?? err)}`
726
773
  };
727
774
  }
728
- return {
729
- ok: true,
730
- detail: `已即时投递 ${t.dirName}(${acquired.how})`
731
- };
732
775
  }
733
776
  /**
734
777
  * 本 tick 要扫的 CCC 根集合(**并集**)。
@@ -773,11 +816,15 @@ async function runWakeTick(ctx) {
773
816
  }
774
817
  for (const e of due) {
775
818
  const res = await deliverWake(ctx, root, e);
819
+ if (res.notReady) {
820
+ log.push(`· ${root}: ${e.id} → 跳过(宿主服务未就绪,下个 tick 再试;不计失败)`);
821
+ continue;
822
+ }
776
823
  updateWake(root, e.id, {
777
824
  state: res.ok ? "delivered" : "pending",
778
825
  attempts: e.attempts + 1,
779
826
  lastResult: res.detail,
780
- deliveredAt: res.ok ? (/* @__PURE__ */ new Date()).toISOString() : null
827
+ deliveredAt: res.ok ? isoLocal() : null
781
828
  });
782
829
  log.push(`· ${root}: ${e.id} → ${res.ok ? "delivered" : `重试(${res.detail})`}`);
783
830
  }
@@ -14,7 +14,8 @@
14
14
  * → 回退:live 会话标题匹配
15
15
  * 3. agent:**live 优先**(`ctx.agents.get`)→ 冷会话走 `ctx.sessionController.resolveAgent`
16
16
  * (宿主实现:live 优先 + resume 去重 + 由会话元数据恢复 preset)
17
- * → `sessionController` 缺席(headless profile)⇒ 仅投递 live 目标,**不静默**
17
+ * → `sessionController` 缺席 仅投递 live 目标,**不静默**(v1.39.3:**归为"环境未就绪"**
18
+ * 而非"投递失败",见 `WakeDeliveryResult.notReady` —— 启动那一拍早于懒服务就绪是常态)
18
19
  * 4. 投递:`agent.followup(...)`("Queue an ordinary follow-up turn and wake the driver")
19
20
  *
20
21
  * 守卫(I-4 默认 + v1.34 S-1 解耦):全局 `wakeSchedulerEnabled`(**缺省开**;**不再**回退旧键
@@ -28,6 +29,19 @@ import { type WakeEntry } from './wake-registry.js';
28
29
  interface WakeDeliveryResult {
29
30
  ok: boolean;
30
31
  detail: string;
32
+ /**
33
+ * 🔴 **环境未就绪**(不是投递失败)——只有一种情形:目标需要**冷载入**,而宿主此刻**还没把
34
+ * 懒服务 `sessionController` 供上来**(典型 = `dsh web` 刚重启:时钟武装时**立刻跑一次 tick**,
35
+ * 那一拍早于懒服务就绪、也早于会话恢复)。
36
+ *
37
+ * 为什么要把它和"真失败"分开(2026-09-17 实测,S142 §0t):
38
+ * 旧实现把这一情形当成投递失败写进条目 ⇒ ① `attempts` 无端 +1 ② `lastResult` 留下一条
39
+ * **误导性错误**(原文猜"headless profile?",而真因是**重启窗口**,属**假陈述**)
40
+ * ③ 注册表/面板里出现"失败的投递",**每次 restart 都必然复现**——一个**假告警发生器**
41
+ * (所有者 2026-09-17 就是被它引来的)。而消息**从未丢失**:下一个 tick 就投成功了。
42
+ * ⇒ 判据:**"宿主服务还没起来"不是投递结果**,不得计入条目。
43
+ */
44
+ notReady?: boolean;
31
45
  }
32
46
  /**
33
47
  * 调度器**进程态**(诊断用,模块级 = 进程级,正是诊断对象)。
@@ -72,8 +86,12 @@ export declare function buildWakeText(entry: WakeEntry, target: WakeTarget): str
72
86
  export declare function deliverWake(ctx: Context, root: string, entry: WakeEntry): Promise<WakeDeliveryResult>;
73
87
  /**
74
88
  * 即时消息正文(**刻意不复用** {@link buildWakeText}):唤醒文本以「**到点**(登记于…)」开头,
75
- * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"
76
- * 并写明"消息已入队、若此前在跑轮次则本轮结束后生效"(防目标误判时序)。
89
+ * 对即时投递那是**错的事实**。本函数显式区分"这是即时消息、不是到点唤醒"
90
+ *
91
+ * ⚠️ **v1.39.2 起"已入队"这句话必须删掉**(S142 §0r,所有者令「不排队、直接即时注入」):
92
+ * 旧文案写"消息已进入你的队列;你此前若正在跑轮次,它在本轮结束后生效"——
93
+ * 那是**旧的 followup 语义**;现在在跑的目标走 `steer`(当场注入当前轮),
94
+ * 留着这句话就是**注入一条假陈述**(比没有更坏:目标会误判自己看到它的时机)。
77
95
  * @param target 目标轨迹(目录名 + SESSION.md)
78
96
  * @param message 递送正文
79
97
  * @param sender 发起者轨迹标识(供目标与人类识别来源)
@@ -83,7 +101,7 @@ export declare function buildSendText(target: WakeTarget, message: string, sende
83
101
  * 即时投递(`container_trajectory send-now`)——与 `send-later`(预约)**共用取用通路**(`acquireWakeAgent`),
84
102
  * 区别只在**时刻**(现在 vs 未来)与**回执**(有 vs 无):
85
103
  * · `send-later`(原 `wake-later`):未来时刻 + 一条 message → 落注册表 → 本文件的调度器到点投递(**fire-and-forget,无回执**)
86
- * · `send-now`(原 `send-message`):**此刻**把一条 message 递进目标队列 → **同步回执** → **不落注册表**
104
+ * · `send-now`(原 `send-message`):**此刻**把一条 message 递给目标 → **同步回执** → **不落注册表**
87
105
  *
88
106
  * **为什么冷路径落在这里(R↓,2026-09-16 所有者裁决)**:`addWake` 显式拒绝 `at ≤ now`
89
107
  * ("时刻必须在未来"——那是"**预约**"语义的地基,不许放宽)⇒ 即时投递**不能**靠一条
@@ -91,10 +109,20 @@ export declare function buildSendText(target: WakeTarget, message: string, sende
91
109
  * 必须直接走 {@link acquireWakeAgent}(live 优先 → `sessionController.resolveAgent` 冷载入)。
92
110
  * 这正对应所有者要的「**会话不活跃则等效于直接 wake**」:**同一条冷载入通路**,只是**不等 tick**。
93
111
  *
94
- * ⚠️ **承诺边界(不许含糊)**:回执只到「**已入队**」(`followup` 未抛错),
95
- * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。目标若正在跑轮次,
96
- * 消息**排在轮次边界**(**不打断当前轮**)。判"目标真的动了"必须用**文件级判据**
97
- * (它自己的 SESSION.md),**不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
112
+ * ⚠️ **承诺边界(不许含糊)**:回执只到「**已注入 / 已起轮**」(宿主调用未抛错),
113
+ * **到不了**"目标已执行/已答复" —— 与 `deliverWake` 同一层。
114
+ * "目标真的动了"必须用**文件级判据**(它自己的 SESSION.md),
115
+ * **不得凭回执结案**(§12.B 的 `delivered ≠ 跑了一轮`)。
116
+ *
117
+ * 🔴 **v1.39.2 语义变更(S142 §0r,所有者 2026-09-17 令:「希望 send message 时不排队,
118
+ * 可以直接作为提示词即时注入进去」)**:
119
+ * 旧实现一律 `followup`(宿主注释:*Queue an ordinary follow-up turn*)⇒ 目标**正在跑轮次**时
120
+ * 消息**排在轮次边界**(即旧文案的"不打断当前轮")。现按宿主 `agent.status` 分流:
121
+ * · `status === 'idle'` ⇒ `followup`(宿主**同步**把 status 置 running ⇒ **立即起轮**,本就不排队)
122
+ * · 否则(在跑) ⇒ **`steer`** —— **当场注入当前轮**,这才是"不排队"的关键路径
123
+ * ⚠️ **fail-safe**:`steer` 抛错 ⇒ **退回 `followup`**:**宁可排队,不可静默丢消息**。
124
+ * ⚠️ **只改 `send-now`**:`send-later`({@link deliverWake})**保持 `followup`** ——
125
+ * 预约的本职是"到点唤起",且在跑时排队不打断是它被实证验收过的行为,不顺手动它。
98
126
  *
99
127
  * **归属**:本函数放在调度器侧而非工具侧 —— "怎么把一句话送到一条轨迹"是**调度器的知识**,
100
128
  * 工具只该知道"有这个动作"。这样 `trajectory.ts` 完全不碰调度器内部(v1.35.0 硬约束①:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shgroup/dsh-serenity-hooks",
3
- "version": "1.39.1",
3
+ "version": "1.39.3",
4
4
  "description": "宁静号 ACC harness(Native Cordis 插件)——给 DeepSeek Harness 装一个「AI 工作区」:11 个工具(container_fs/container_trajectory/dashboard/container_git/msm/praxis/handyman/localstore/container_admin/im-bridge/acc-diag)+ 机械约束(安全模式/工作区围墙/密钥守卫/对外输出守卫)+ 工作日志与原地重建 + 网页登录入口/微信桥/子角色/对外问答页/trajectory 唤醒注册表。适配 DSH 0.1.5-rc.2。",
5
5
  "license": "MIT",
6
6
  "repository": {