@lexwdex-org/opencode-dcp 3.4.14 → 3.5.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/dist/index.js CHANGED
@@ -1,42 +1,3 @@
1
- // lib/activity.ts
2
- var BUSY_TTL_MS = 10 * 6e4;
3
- var MAX_TRACKED_SESSIONS = 500;
4
- var SessionActivityTracker = class {
5
- sessions = /* @__PURE__ */ new Map();
6
- now;
7
- constructor(now) {
8
- this.now = now ?? Date.now;
9
- }
10
- observe(type, properties) {
11
- const sessionID = properties?.sessionID;
12
- if (typeof sessionID !== "string" || !sessionID) return;
13
- if (type === "session.status") {
14
- const status = properties?.status?.type;
15
- if (status === "busy" || status === "retry") this.set(sessionID, "busy");
16
- else if (status === "idle") this.set(sessionID, "idle");
17
- return;
18
- }
19
- if (type === "session.idle") this.set(sessionID, "idle");
20
- }
21
- state(sessionID) {
22
- const activity = this.sessions.get(sessionID);
23
- if (!activity) return "unknown";
24
- if (activity.state === "busy" && this.now() - activity.at > BUSY_TTL_MS) return "unknown";
25
- return activity.state;
26
- }
27
- dropSession(sessionID) {
28
- this.sessions.delete(sessionID);
29
- }
30
- set(sessionID, state) {
31
- if (this.sessions.has(sessionID)) this.sessions.delete(sessionID);
32
- this.sessions.set(sessionID, { state, at: this.now() });
33
- if (this.sessions.size > MAX_TRACKED_SESSIONS) {
34
- const oldest = this.sessions.keys().next().value;
35
- if (oldest !== void 0) this.sessions.delete(oldest);
36
- }
37
- }
38
- };
39
-
40
1
  // lib/auto-prune.ts
41
2
  function tokenize(text) {
42
3
  const tokens = /* @__PURE__ */ new Set();
@@ -121,10 +82,11 @@ var AutoPruner = class {
121
82
  evaluate(state, text, at) {
122
83
  if (state.count + 1 < this.config.minMessages) return [];
123
84
  const signals = [];
124
- if (state.count > 0 && at - state.lastAt >= this.config.idleGapMs) {
85
+ const enabled = this.config.signals;
86
+ if (enabled.idleGap && state.count > 0 && at - state.lastAt >= this.config.idleGapMs) {
125
87
  signals.push("idle-gap");
126
88
  }
127
- if (state.count >= DRIFT_BASELINE && text) {
89
+ if (enabled.topicDrift && state.count >= DRIFT_BASELINE && text) {
128
90
  const current = tokenize(text);
129
91
  if (current.size >= MIN_DRIFT_TOKENS) {
130
92
  let max = 0;
@@ -134,7 +96,7 @@ var AutoPruner = class {
134
96
  if (max < this.config.driftThreshold) signals.push("topic-drift");
135
97
  }
136
98
  }
137
- if (state.count + 1 >= this.config.volumeThreshold) signals.push("volume");
99
+ if (enabled.volume && state.count + 1 >= this.config.volumeThreshold) signals.push("volume");
138
100
  return signals;
139
101
  }
140
102
  state(sessionID) {
@@ -1019,6 +981,7 @@ var ParseErrorCode;
1019
981
  var DEFAULT_FAILURE_COOLDOWN_MS = 3e4;
1020
982
  var DEFAULT_AUTO_PRUNE = {
1021
983
  enabled: true,
984
+ signals: { topicDrift: true, volume: false, idleGap: false },
1022
985
  minMessages: 8,
1023
986
  volumeThreshold: 30,
1024
987
  driftThreshold: 0.18,
@@ -1039,6 +1002,10 @@ var VALID_CONFIG_KEYS = /* @__PURE__ */ new Set([
1039
1002
  "summarize.failureCooldownMs",
1040
1003
  "autoPrune",
1041
1004
  "autoPrune.enabled",
1005
+ "autoPrune.signals",
1006
+ "autoPrune.signals.topicDrift",
1007
+ "autoPrune.signals.volume",
1008
+ "autoPrune.signals.idleGap",
1042
1009
  "autoPrune.minMessages",
1043
1010
  "autoPrune.volumeThreshold",
1044
1011
  "autoPrune.driftThreshold",
@@ -1213,6 +1180,27 @@ function validateConfigTypes(config) {
1213
1180
  });
1214
1181
  }
1215
1182
  }
1183
+ const signals = autoPrune.signals;
1184
+ if (signals !== void 0) {
1185
+ if (typeof signals !== "object" || signals === null || Array.isArray(signals)) {
1186
+ errors.push({
1187
+ key: "autoPrune.signals",
1188
+ expected: "object",
1189
+ actual: typeof signals
1190
+ });
1191
+ } else {
1192
+ for (const key of ["topicDrift", "volume", "idleGap"]) {
1193
+ const value = signals[key];
1194
+ if (value !== void 0 && typeof value !== "boolean") {
1195
+ errors.push({
1196
+ key: `autoPrune.signals.${key}`,
1197
+ expected: "boolean",
1198
+ actual: typeof value
1199
+ });
1200
+ }
1201
+ }
1202
+ }
1203
+ }
1216
1204
  }
1217
1205
  }
1218
1206
  const tool2 = config.tool;
@@ -1233,15 +1221,25 @@ function validateConfigTypes(config) {
1233
1221
  }
1234
1222
  return errors;
1235
1223
  }
1224
+ function needsSignalsMigrationHint(configData) {
1225
+ const autoPrune = configData.autoPrune;
1226
+ return autoPrune !== null && typeof autoPrune === "object" && !Array.isArray(autoPrune) && autoPrune.enabled === true && autoPrune.signals === void 0;
1227
+ }
1236
1228
  function showConfigWarnings(ctx, configPath, configData, isProject) {
1237
1229
  const invalidKeys = getInvalidConfigKeys(configData);
1238
1230
  const deprecatedKeys = getDeprecatedConfigKeys(configData);
1239
1231
  const typeErrors = validateConfigTypes(configData);
1240
- if (invalidKeys.length === 0 && deprecatedKeys.length === 0 && typeErrors.length === 0) {
1232
+ const signalsHint = needsSignalsMigrationHint(configData);
1233
+ if (!signalsHint && invalidKeys.length === 0 && deprecatedKeys.length === 0 && typeErrors.length === 0) {
1241
1234
  return;
1242
1235
  }
1243
1236
  const configType = isProject ? "project config" : "config";
1244
1237
  const messages = [];
1238
+ if (signalsHint) {
1239
+ messages.push(
1240
+ "auto-prune signals `volume` and `idleGap` are now disabled by default; re-enable via autoPrune.signals.*"
1241
+ );
1242
+ }
1245
1243
  if (deprecatedKeys.length > 0) {
1246
1244
  const keyList = deprecatedKeys.slice(0, 3).join(", ");
1247
1245
  const suffix = deprecatedKeys.length > 3 ? ` (+${deprecatedKeys.length - 3} more)` : "";
@@ -1390,8 +1388,15 @@ function mergeAutoPrune(base, override) {
1390
1388
  return base;
1391
1389
  }
1392
1390
  const number = (key, min, max = Number.POSITIVE_INFINITY) => typeof override[key] === "number" && Number.isFinite(override[key]) && override[key] >= min && override[key] <= max ? override[key] : base[key];
1391
+ const signalsOverride = override.signals;
1392
+ const signals = signalsOverride && typeof signalsOverride === "object" && !Array.isArray(signalsOverride) ? {
1393
+ topicDrift: typeof signalsOverride.topicDrift === "boolean" ? signalsOverride.topicDrift : base.signals.topicDrift,
1394
+ volume: typeof signalsOverride.volume === "boolean" ? signalsOverride.volume : base.signals.volume,
1395
+ idleGap: typeof signalsOverride.idleGap === "boolean" ? signalsOverride.idleGap : base.signals.idleGap
1396
+ } : base.signals;
1393
1397
  return {
1394
1398
  enabled: typeof override.enabled === "boolean" ? override.enabled : base.enabled,
1399
+ signals,
1395
1400
  minMessages: number("minMessages", 1),
1396
1401
  volumeThreshold: number("volumeThreshold", 2),
1397
1402
  driftThreshold: number("driftThreshold", 0, 1),
@@ -1413,7 +1418,7 @@ function deepCloneConfig(config) {
1413
1418
  commands: { ...config.commands },
1414
1419
  experimental: { ...config.experimental },
1415
1420
  summarize: { ...config.summarize },
1416
- autoPrune: { ...config.autoPrune },
1421
+ autoPrune: { ...config.autoPrune, signals: { ...config.autoPrune.signals } },
1417
1422
  tool: { ...config.tool }
1418
1423
  };
1419
1424
  }
@@ -1480,164 +1485,232 @@ Using previous/default values`
1480
1485
  return config;
1481
1486
  }
1482
1487
 
1483
- // lib/session-model.ts
1484
- function latestUserModel(messages) {
1485
- if (!Array.isArray(messages)) return null;
1486
- for (let index = messages.length - 1; index >= 0; index--) {
1487
- const info = messages[index]?.info;
1488
- if (info?.role !== "user") continue;
1489
- const providerID = info.model?.providerID;
1490
- const modelID = info.model?.modelID;
1491
- if (typeof providerID === "string" && typeof modelID === "string") {
1492
- return { providerID, modelID };
1493
- }
1494
- }
1495
- return null;
1496
- }
1497
- async function resolveSessionModel(client, sessionID) {
1498
- try {
1499
- const response = await client.session.messages({ path: { id: sessionID } });
1500
- return latestUserModel(response.data ?? response);
1501
- } catch {
1502
- return null;
1503
- }
1504
- }
1505
-
1506
- // lib/prune-service.ts
1507
- function retrySeconds(retryAfterMs) {
1508
- return Math.ceil(retryAfterMs / 1e3);
1509
- }
1510
- function eventSessionID(properties) {
1511
- const direct = properties?.sessionID;
1512
- if (typeof direct === "string" && direct) return direct;
1513
- const info = properties?.info?.id;
1514
- if (typeof info === "string" && info) return info;
1515
- return void 0;
1516
- }
1517
- var PruneService = class {
1518
- deferred = /* @__PURE__ */ new Set();
1519
- client;
1520
- summarize;
1521
- activity;
1488
+ // lib/session-boundary.ts
1489
+ var BOUNDARY_QUIET_MS = 2e3;
1490
+ var PROBE_TIMEOUT_MS = 2e3;
1491
+ var BUSY_EVIDENCE_TTL_MS = 10 * 6e4;
1492
+ var MAX_TRACKED_SESSIONS = 500;
1493
+ var SessionBoundaryTracker = class {
1494
+ sessions = /* @__PURE__ */ new Map();
1495
+ listeners = [];
1496
+ probeBusy;
1522
1497
  logger;
1498
+ now;
1499
+ setTimer;
1500
+ clearTimer;
1523
1501
  constructor(deps) {
1524
- this.client = deps.client;
1525
- this.summarize = deps.summarize;
1526
- this.activity = deps.activity;
1502
+ this.probeBusy = deps.probeBusy;
1527
1503
  this.logger = deps.logger;
1528
- }
1529
- /**
1530
- * Feed every host event through here. Drains the deferral queue on
1531
- * `session.idle` and forgets queued prunes once a compaction (or the
1532
- * session itself) is gone.
1533
- */
1534
- observeEvent(type, properties) {
1535
- this.activity.observe(type, properties);
1536
- const sessionID = eventSessionID(properties);
1504
+ this.now = deps.now ?? Date.now;
1505
+ this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
1506
+ this.clearTimer = deps.clearTimer ?? ((t) => clearTimeout(t));
1507
+ }
1508
+ /** Register an at-rest listener; listeners fire in registration order
1509
+ * (1. deferred drain, 2. heuristic auto prune) and are individually
1510
+ * error-isolated: a throw or rejection is warn-logged and never blocks
1511
+ * later listeners nor escapes into the timer callback. */
1512
+ onAtRest(listener) {
1513
+ this.listeners.push(listener);
1514
+ }
1515
+ /** Primary input: `session.status` event payload status type. */
1516
+ observeStatus(sessionID, statusType) {
1537
1517
  if (!sessionID) return;
1538
- if (type === "session.idle") {
1539
- if (!this.deferred.delete(sessionID)) return;
1540
- void this.drainQueued(sessionID);
1518
+ const entry = this.entry(sessionID);
1519
+ entry.sawStatus = true;
1520
+ if (statusType === "busy" || statusType === "retry") {
1521
+ this.transition(
1522
+ sessionID,
1523
+ entry,
1524
+ "busy",
1525
+ statusType === "retry" ? "retry-observed" : "busy-observed"
1526
+ );
1541
1527
  return;
1542
1528
  }
1543
- if (type === "session.compacted") {
1544
- this.deferred.delete(sessionID);
1545
- return;
1529
+ if (statusType === "idle") {
1530
+ this.observeIdle(sessionID, entry, "status");
1546
1531
  }
1547
- if (type === "session.deleted") {
1548
- this.deferred.delete(sessionID);
1549
- this.activity.dropSession(sessionID);
1532
+ }
1533
+ /** First-class input: legacy `session.idle`. Absorbed by dedup on hosts
1534
+ * that dual-publish; the only boundary signal on status-less hosts (T3). */
1535
+ observeLegacyIdle(sessionID) {
1536
+ if (!sessionID) return;
1537
+ this.observeIdle(sessionID, this.entry(sessionID), "legacy-idle");
1538
+ }
1539
+ /** Lifecycle: cancel any pending window and forget the session. */
1540
+ observeDeleted(sessionID) {
1541
+ if (!sessionID) return;
1542
+ const entry = this.sessions.get(sessionID);
1543
+ if (!entry) return;
1544
+ this.cancelWindow(entry);
1545
+ this.sessions.delete(sessionID);
1546
+ this.logger.debug("Boundary session dropped", { sessionId: sessionID, reason: "deleted" });
1547
+ }
1548
+ /** Lifecycle: compaction ended; reset to unknown so the next idle opens a
1549
+ * fresh window. */
1550
+ observeCompacted(sessionID) {
1551
+ if (!sessionID) return;
1552
+ const entry = this.sessions.get(sessionID);
1553
+ if (!entry) return;
1554
+ const from = entry.phase;
1555
+ this.cancelWindow(entry);
1556
+ entry.phase = "unknown";
1557
+ delete entry.busyAt;
1558
+ this.logger.debug("Boundary transition", {
1559
+ sessionId: sessionID,
1560
+ from,
1561
+ to: "unknown",
1562
+ reason: "compacted"
1563
+ });
1564
+ }
1565
+ /** Absorbed busy-evidence cache: `busy` decays to `unknown` after the TTL;
1566
+ * the table is LRU-bounded (pending-rest/at-rest entries have no TTL exit
1567
+ * and rely on the cap). */
1568
+ state(sessionID) {
1569
+ const entry = this.sessions.get(sessionID);
1570
+ if (!entry) return "unknown";
1571
+ if (entry.phase === "busy" && entry.busyAt !== void 0 && this.now() - entry.busyAt > BUSY_EVIDENCE_TTL_MS) {
1572
+ return "unknown";
1550
1573
  }
1574
+ return entry.phase;
1551
1575
  }
1552
- async request(request) {
1553
- const beforeModel = await this.gate(request);
1554
- if (beforeModel) return beforeModel;
1555
- const model = await resolveSessionModel(this.client, request.sessionID);
1556
- if (!model) return { status: "no-model" };
1557
- const afterModel = await this.gate(request);
1558
- if (afterModel) return afterModel;
1559
- const serverBusy = await this.isBusyOnServer(request.sessionID);
1560
- if (serverBusy === true) {
1561
- const fallback = await this.outcomeFor(request, { action: "stand-down" });
1562
- if (fallback) return fallback;
1576
+ dispose(sessionID) {
1577
+ this.observeDeleted(sessionID);
1578
+ }
1579
+ observeIdle(sessionID, entry, source) {
1580
+ if (entry.phase === "pending-rest") {
1581
+ this.logger.debug("Boundary duplicate idle ignored", { sessionId: sessionID, source });
1582
+ return;
1563
1583
  }
1564
- const result = await this.summarize.summarize({ sessionID: request.sessionID, model });
1565
- if (result.status === "rejected") {
1566
- return { status: "busy" };
1584
+ if (entry.phase === "at-rest") {
1585
+ if (source === "legacy-idle" && !entry.sawStatus) {
1586
+ this.armWindow(sessionID, entry, "degrade-rearm");
1587
+ } else {
1588
+ this.logger.debug("Boundary duplicate idle ignored", {
1589
+ sessionId: sessionID,
1590
+ source
1591
+ });
1592
+ }
1593
+ return;
1567
1594
  }
1568
- return result;
1569
- }
1570
- /** One admission check; `null` means the request may proceed. */
1571
- gate(request) {
1572
- return this.outcomeFor(request, this.admit(request));
1595
+ this.armWindow(sessionID, entry, "idle-observed");
1596
+ }
1597
+ armWindow(sessionID, entry, reason) {
1598
+ this.cancelWindow(entry);
1599
+ entry.phase = "pending-rest";
1600
+ entry.timer = this.setTimer(() => {
1601
+ entry.timer = void 0;
1602
+ void this.onWindowExpiry(sessionID, entry);
1603
+ }, BOUNDARY_QUIET_MS);
1604
+ this.refresh(sessionID, entry);
1605
+ this.logger.debug("Boundary quiet window armed", {
1606
+ sessionId: sessionID,
1607
+ reason,
1608
+ generation: entry.generation
1609
+ });
1573
1610
  }
1574
- /**
1575
- * Executes a queued prune at the idle boundary. The tool already promised
1576
- * its caller this prune would run; losing the busy race must not silently
1577
- * break that promise, so a busy outcome re-queues for the next idle
1578
- * boundary. Every other outcome is terminal: it is logged and never
1579
- * retried — new prune demand arrives through new triggers only.
1580
- * Execution is re-guarded on every attempt, so re-queueing never violates
1581
- * the never-interrupt invariant.
1582
- */
1583
- async drainQueued(sessionID) {
1584
- let outcome;
1611
+ async onWindowExpiry(sessionID, entry) {
1612
+ if (entry.phase !== "pending-rest") return;
1613
+ const generation = entry.generation;
1614
+ let busy;
1585
1615
  try {
1586
- outcome = await this.request({ sessionID, onBusy: "proceed" });
1587
- } catch (error) {
1588
- this.logger.warn("Queued prune drain failed; the prune was not retried", {
1616
+ busy = await this.probeBusy(sessionID);
1617
+ } catch {
1618
+ busy = null;
1619
+ }
1620
+ if (entry.phase !== "pending-rest" || entry.generation !== generation || this.sessions.get(sessionID) !== entry) {
1621
+ this.logger.debug("Boundary probe result discarded", {
1589
1622
  sessionId: sessionID,
1590
- error: error instanceof Error ? error.message : String(error)
1623
+ generation
1591
1624
  });
1592
1625
  return;
1593
1626
  }
1594
- if (outcome.status === "busy") {
1595
- this.deferred.add(sessionID);
1627
+ if (busy === true) {
1628
+ this.transition(sessionID, entry, "busy", "probe-busy");
1596
1629
  return;
1597
1630
  }
1598
- this.logger.debug("Queued prune drain finished", {
1631
+ const reason = busy === false ? "window-expired" : "probe-failopen";
1632
+ const from = entry.phase;
1633
+ entry.phase = "at-rest";
1634
+ this.refresh(sessionID, entry);
1635
+ this.logger.debug("Boundary transition", {
1599
1636
  sessionId: sessionID,
1600
- status: outcome.status
1637
+ from,
1638
+ to: "at-rest",
1639
+ reason
1601
1640
  });
1641
+ this.fireAtRest(sessionID, reason);
1642
+ }
1643
+ transition(sessionID, entry, phase, reason) {
1644
+ const from = entry.phase;
1645
+ this.cancelWindow(entry);
1646
+ entry.phase = phase;
1647
+ if (phase === "busy") entry.busyAt = this.now();
1648
+ else delete entry.busyAt;
1649
+ this.refresh(sessionID, entry);
1650
+ this.logger.debug("Boundary transition", { sessionId: sessionID, from, to: phase, reason });
1651
+ }
1652
+ cancelWindow(entry) {
1653
+ if (entry.timer !== void 0) {
1654
+ this.clearTimer(entry.timer);
1655
+ entry.timer = void 0;
1656
+ }
1657
+ entry.generation += 1;
1658
+ }
1659
+ fireAtRest(sessionID, reason) {
1660
+ for (const listener of this.listeners) {
1661
+ try {
1662
+ const result = listener(sessionID, reason);
1663
+ if (result instanceof Promise) {
1664
+ result.catch((error) => {
1665
+ this.logger.warn("At-rest listener rejected", {
1666
+ sessionId: sessionID,
1667
+ error: error instanceof Error ? error.message : String(error)
1668
+ });
1669
+ });
1670
+ }
1671
+ } catch (error) {
1672
+ this.logger.warn("At-rest listener failed", {
1673
+ sessionId: sessionID,
1674
+ error: error instanceof Error ? error.message : String(error)
1675
+ });
1676
+ }
1677
+ }
1602
1678
  }
1603
- /**
1604
- * Turns an admission decision into a terminal outcome, or `null` when the
1605
- * request may proceed. Deferral additionally enqueues the session.
1606
- */
1607
- async outcomeFor(request, admission) {
1608
- if (admission.action === "go") return null;
1609
- if (admission.action === "stand-down") return { status: "busy" };
1610
- this.deferred.add(request.sessionID);
1611
- this.logger.debug("Prune deferred to the next session idle boundary", {
1612
- sessionId: request.sessionID
1613
- });
1614
- return { status: "deferred" };
1679
+ entry(sessionID) {
1680
+ let entry = this.sessions.get(sessionID);
1681
+ if (!entry) {
1682
+ entry = { phase: "unknown", generation: 0, sawStatus: false };
1683
+ this.sessions.set(sessionID, entry);
1684
+ this.evictIfNeeded(sessionID);
1685
+ return entry;
1686
+ }
1687
+ this.refresh(sessionID, entry);
1688
+ return entry;
1615
1689
  }
1616
- admit(request) {
1617
- if (request.onBusy === "defer") return { action: "defer" };
1618
- if (this.activity.state(request.sessionID) === "busy") return { action: "stand-down" };
1619
- return { action: "go" };
1690
+ refresh(sessionID, entry) {
1691
+ this.sessions.delete(sessionID);
1692
+ this.sessions.set(sessionID, entry);
1693
+ this.evictIfNeeded(sessionID);
1620
1694
  }
1621
- /**
1622
- * Queries the host's live session status. Returns `null` when the answer
1623
- * is unknown (endpoint or SDK method missing, request failed) — callers
1624
- * must fail open in that case.
1625
- */
1626
- async isBusyOnServer(sessionID) {
1627
- try {
1628
- const statusFn = this.client.session.status;
1629
- if (typeof statusFn !== "function") return null;
1630
- const response = await statusFn.call(
1631
- this.client.session
1632
- );
1633
- const map = response?.data ?? response;
1634
- const info = map?.[sessionID];
1635
- return info?.type === "busy" || info?.type === "retry";
1636
- } catch {
1637
- return null;
1638
- }
1695
+ evictIfNeeded(kept) {
1696
+ if (this.sessions.size <= MAX_TRACKED_SESSIONS) return;
1697
+ const oldest = this.sessions.keys().next().value;
1698
+ if (oldest === void 0 || oldest === kept) return;
1699
+ const victim = this.sessions.get(oldest);
1700
+ if (victim?.timer !== void 0) this.clearTimer(victim.timer);
1701
+ this.sessions.delete(oldest);
1639
1702
  }
1640
1703
  };
1704
+ function retrySeconds(retryAfterMs) {
1705
+ return Math.ceil(retryAfterMs / 1e3);
1706
+ }
1707
+ function eventSessionID(properties) {
1708
+ const direct = properties?.sessionID;
1709
+ if (typeof direct === "string" && direct) return direct;
1710
+ const info = properties?.info?.id;
1711
+ if (typeof info === "string" && info) return info;
1712
+ return void 0;
1713
+ }
1641
1714
 
1642
1715
  // lib/hooks.ts
1643
1716
  function createSessionCompactingHandler(prompts, logger) {
@@ -1676,57 +1749,12 @@ var SIGNAL_LABELS = {
1676
1749
  "idle-gap": "\u957F\u65F6\u95F4\u4E2D\u65AD\u540E\u6062\u590D"
1677
1750
  };
1678
1751
  function createEventHandler(deps) {
1679
- async function triggerAutoPrune(sessionID, signals) {
1680
- const reason = signals.map((signal) => SIGNAL_LABELS[signal]).join("\u3001");
1681
- const result = await deps.prune.request({ sessionID, onBusy: "proceed" });
1682
- const attempted = result.status === "succeeded" || result.status === "failed" || result.status === "cooldown";
1683
- if (!attempted) {
1684
- if (result.status === "busy") {
1685
- await showToast(
1686
- deps.client,
1687
- "DCP \u81EA\u52A8\u538B\u7F29",
1688
- `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u4F1A\u8BDD\u6B63\u5FD9\uFF0C\u672C\u6B21\u5DF2\u8DF3\u8FC7\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1689
- "warning"
1690
- );
1691
- }
1692
- deps.logger.debug("Auto prune skipped", {
1693
- sessionId: sessionID,
1694
- status: result.status
1695
- });
1696
- return;
1697
- }
1698
- deps.autoPruner.markPruned(sessionID);
1699
- if (result.status === "succeeded") {
1700
- await showToast(deps.client, "DCP \u81EA\u52A8\u538B\u7F29", `\u68C0\u6D4B\u5230${reason}\uFF0C\u5DF2\u751F\u6210\u65B0\u7684\u8BED\u4E49\u68C0\u67E5\u70B9\u3002`);
1701
- } else if (result.status === "cooldown") {
1702
- await showToast(
1703
- deps.client,
1704
- "DCP \u81EA\u52A8\u538B\u7F29",
1705
- `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u4E0A\u4E00\u6B21\u538B\u7F29\u5931\u8D25\uFF1B${retrySeconds(result.retryAfterMs)} \u79D2\u540E\u53EF\u91CD\u8BD5\u3002`,
1706
- "warning"
1707
- );
1708
- } else {
1709
- await showToast(
1710
- deps.client,
1711
- "DCP \u81EA\u52A8\u538B\u7F29",
1712
- `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u538B\u7F29\u5931\u8D25\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1713
- "warning"
1714
- );
1715
- }
1716
- deps.logger.debug("Auto prune finished", { sessionId: sessionID, status: result.status });
1717
- }
1718
1752
  return async (input) => {
1719
1753
  const event = input.event;
1720
- const sessionID = eventSessionID(event.properties);
1721
- if (!sessionID) return;
1722
1754
  try {
1723
1755
  deps.prune.observeEvent(event.type, event.properties);
1724
- if (event.type === "session.idle") {
1725
- if (!deps.config.enabled) return;
1726
- const signals = deps.autoPruner.consumePending(sessionID);
1727
- if (signals) await triggerAutoPrune(sessionID, signals);
1728
- return;
1729
- }
1756
+ const sessionID = eventSessionID(event.properties);
1757
+ if (!sessionID) return;
1730
1758
  if (event.type === "session.compacted") {
1731
1759
  deps.autoPruner.markPruned(sessionID);
1732
1760
  return;
@@ -1737,12 +1765,57 @@ function createEventHandler(deps) {
1737
1765
  } catch (error) {
1738
1766
  deps.logger.warn("Event handler failed", {
1739
1767
  type: event.type,
1740
- sessionId: sessionID,
1741
1768
  error: error instanceof Error ? error.message : String(error)
1742
1769
  });
1743
1770
  }
1744
1771
  };
1745
1772
  }
1773
+ async function triggerAutoPrune(deps, sessionID, signals) {
1774
+ const reason = signals.map((signal) => SIGNAL_LABELS[signal]).join("\u3001");
1775
+ const result = await deps.prune.request({ sessionID, onBusy: "proceed" });
1776
+ const attempted = result.status === "succeeded" || result.status === "failed" || result.status === "cooldown";
1777
+ if (!attempted) {
1778
+ if (result.status === "busy") {
1779
+ await showToast(
1780
+ deps.client,
1781
+ "DCP \u81EA\u52A8\u538B\u7F29",
1782
+ `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u4F1A\u8BDD\u6B63\u5FD9\uFF0C\u672C\u6B21\u5DF2\u8DF3\u8FC7\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1783
+ "warning"
1784
+ );
1785
+ }
1786
+ deps.logger.debug("Auto prune skipped", {
1787
+ sessionId: sessionID,
1788
+ status: result.status
1789
+ });
1790
+ return;
1791
+ }
1792
+ deps.autoPruner.markPruned(sessionID);
1793
+ if (result.status === "succeeded") {
1794
+ await showToast(deps.client, "DCP \u81EA\u52A8\u538B\u7F29", `\u68C0\u6D4B\u5230${reason}\uFF0C\u5DF2\u751F\u6210\u65B0\u7684\u8BED\u4E49\u68C0\u67E5\u70B9\u3002`);
1795
+ } else if (result.status === "cooldown") {
1796
+ await showToast(
1797
+ deps.client,
1798
+ "DCP \u81EA\u52A8\u538B\u7F29",
1799
+ `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u4E0A\u4E00\u6B21\u538B\u7F29\u5931\u8D25\uFF1B${retrySeconds(result.retryAfterMs)} \u79D2\u540E\u53EF\u91CD\u8BD5\u3002`,
1800
+ "warning"
1801
+ );
1802
+ } else {
1803
+ await showToast(
1804
+ deps.client,
1805
+ "DCP \u81EA\u52A8\u538B\u7F29",
1806
+ `\u68C0\u6D4B\u5230${reason}\uFF0C\u4F46\u538B\u7F29\u5931\u8D25\uFF1B\u539F\u59CB\u4E0A\u4E0B\u6587\u4FDD\u6301\u4E0D\u53D8\u3002`,
1807
+ "warning"
1808
+ );
1809
+ }
1810
+ deps.logger.debug("Auto prune finished", { sessionId: sessionID, status: result.status });
1811
+ }
1812
+ function createAtRestAutoPruneListener(deps) {
1813
+ return async (sessionID, _reason) => {
1814
+ if (!deps.config.enabled) return;
1815
+ const signals = deps.autoPruner.consumePending(sessionID);
1816
+ if (signals) await triggerAutoPrune(deps, sessionID, signals);
1817
+ };
1818
+ }
1746
1819
  function createCommandExecuteHandler(client, prune, logger) {
1747
1820
  return async (input, _output) => {
1748
1821
  if (input.command !== "dcp") return;
@@ -2140,6 +2213,184 @@ var PromptStore = class {
2140
2213
 
2141
2214
  // lib/prune-tool.ts
2142
2215
  import { tool } from "@opencode-ai/plugin";
2216
+
2217
+ // lib/session-model.ts
2218
+ function latestUserModel(messages) {
2219
+ if (!Array.isArray(messages)) return null;
2220
+ for (let index = messages.length - 1; index >= 0; index--) {
2221
+ const info = messages[index]?.info;
2222
+ if (info?.role !== "user") continue;
2223
+ const providerID = info.model?.providerID;
2224
+ const modelID = info.model?.modelID;
2225
+ if (typeof providerID === "string" && typeof modelID === "string") {
2226
+ return { providerID, modelID };
2227
+ }
2228
+ }
2229
+ return null;
2230
+ }
2231
+ async function resolveSessionModel(client, sessionID) {
2232
+ try {
2233
+ const response = await client.session.messages({ path: { id: sessionID } });
2234
+ return latestUserModel(response.data ?? response);
2235
+ } catch {
2236
+ return null;
2237
+ }
2238
+ }
2239
+
2240
+ // lib/prune-service.ts
2241
+ var PruneService = class {
2242
+ deferred = /* @__PURE__ */ new Set();
2243
+ client;
2244
+ summarize;
2245
+ logger;
2246
+ probeTimeoutMs;
2247
+ /** THE single busy/idle event state machine (absorbed the former
2248
+ * SessionActivityTracker — no second busy cache may exist). */
2249
+ boundary;
2250
+ constructor(deps) {
2251
+ this.client = deps.client;
2252
+ this.summarize = deps.summarize;
2253
+ this.logger = deps.logger;
2254
+ this.probeTimeoutMs = deps.probeTimeoutMs ?? PROBE_TIMEOUT_MS;
2255
+ this.boundary = new SessionBoundaryTracker({
2256
+ probeBusy: (sessionID) => this.probeBusy(sessionID),
2257
+ logger: deps.logger,
2258
+ now: deps.now,
2259
+ setTimer: deps.setTimer,
2260
+ clearTimer: deps.clearTimer
2261
+ });
2262
+ this.boundary.onAtRest((sessionID) => {
2263
+ if (!this.deferred.delete(sessionID)) return;
2264
+ void this.drainQueued(sessionID);
2265
+ });
2266
+ }
2267
+ /**
2268
+ * Feed every host event through here. `session.status` and legacy
2269
+ * `session.idle` feed the boundary tracker (quiet window + expiry probe);
2270
+ * the deferral queue drains at the confirmed at-rest classification, and
2271
+ * queued prunes are forgotten once a compaction (or the session itself)
2272
+ * is gone.
2273
+ */
2274
+ observeEvent(type, properties) {
2275
+ const sessionID = eventSessionID(properties);
2276
+ if (type === "session.status") {
2277
+ this.boundary.observeStatus(sessionID, properties?.status?.type);
2278
+ return;
2279
+ }
2280
+ if (type === "session.idle") {
2281
+ this.boundary.observeLegacyIdle(sessionID);
2282
+ return;
2283
+ }
2284
+ if (!sessionID) return;
2285
+ if (type === "session.compacted") {
2286
+ this.deferred.delete(sessionID);
2287
+ this.boundary.observeCompacted(sessionID);
2288
+ return;
2289
+ }
2290
+ if (type === "session.deleted") {
2291
+ this.deferred.delete(sessionID);
2292
+ this.boundary.observeDeleted(sessionID);
2293
+ }
2294
+ }
2295
+ async request(request) {
2296
+ const beforeModel = await this.gate(request);
2297
+ if (beforeModel) return beforeModel;
2298
+ const model = await resolveSessionModel(this.client, request.sessionID);
2299
+ if (!model) return { status: "no-model" };
2300
+ const afterModel = await this.gate(request);
2301
+ if (afterModel) return afterModel;
2302
+ const serverBusy = await this.probeBusy(request.sessionID);
2303
+ if (serverBusy === true) {
2304
+ const fallback = await this.outcomeFor(request, { action: "stand-down" });
2305
+ if (fallback) return fallback;
2306
+ }
2307
+ const result = await this.summarize.summarize({ sessionID: request.sessionID, model });
2308
+ if (result.status === "rejected") {
2309
+ return { status: "busy" };
2310
+ }
2311
+ return result;
2312
+ }
2313
+ /** One admission check; `null` means the request may proceed. */
2314
+ gate(request) {
2315
+ return this.outcomeFor(request, this.admit(request));
2316
+ }
2317
+ /**
2318
+ * Executes a queued prune at the confirmed at-rest boundary. The tool
2319
+ * already promised its caller this prune would run; losing the busy race
2320
+ * must not silently break that promise, so a busy outcome re-queues for
2321
+ * the next at-rest boundary. Every other outcome is terminal: it is
2322
+ * logged and never retried — new prune demand arrives through new
2323
+ * triggers only. Execution is re-guarded on every attempt, so re-queueing
2324
+ * never violates the never-interrupt invariant.
2325
+ */
2326
+ async drainQueued(sessionID) {
2327
+ let outcome;
2328
+ try {
2329
+ outcome = await this.request({ sessionID, onBusy: "proceed" });
2330
+ } catch (error) {
2331
+ this.logger.warn("Queued prune drain failed; the prune was not retried", {
2332
+ sessionId: sessionID,
2333
+ error: error instanceof Error ? error.message : String(error)
2334
+ });
2335
+ return;
2336
+ }
2337
+ if (outcome.status === "busy") {
2338
+ this.deferred.add(sessionID);
2339
+ return;
2340
+ }
2341
+ this.logger.debug("Queued prune drain finished", {
2342
+ sessionId: sessionID,
2343
+ status: outcome.status
2344
+ });
2345
+ }
2346
+ /**
2347
+ * Turns an admission decision into a terminal outcome, or `null` when the
2348
+ * request may proceed. Deferral additionally enqueues the session.
2349
+ */
2350
+ async outcomeFor(request, admission) {
2351
+ if (admission.action === "go") return null;
2352
+ if (admission.action === "stand-down") return { status: "busy" };
2353
+ this.deferred.add(request.sessionID);
2354
+ this.logger.debug("Prune deferred to the next session at-rest boundary", {
2355
+ sessionId: request.sessionID
2356
+ });
2357
+ return { status: "deferred" };
2358
+ }
2359
+ admit(request) {
2360
+ if (request.onBusy === "defer") return { action: "defer" };
2361
+ if (this.boundary.state(request.sessionID) === "busy") return { action: "stand-down" };
2362
+ return { action: "go" };
2363
+ }
2364
+ /**
2365
+ * THE single live busy probe, reused verbatim by the boundary tracker's
2366
+ * expiry check. Bounded by a finite deadline: a never-returning probe
2367
+ * resolves `null` within the timeout (fail-open). Returns `null` when the
2368
+ * answer is unknown (endpoint or SDK method missing, request failed,
2369
+ * timeout) — callers must fail open in that case.
2370
+ */
2371
+ async probeBusy(sessionID) {
2372
+ const statusFn = this.client.session.status;
2373
+ if (typeof statusFn !== "function") return null;
2374
+ const query = (async () => {
2375
+ try {
2376
+ return await statusFn.call(
2377
+ this.client.session
2378
+ );
2379
+ } catch {
2380
+ return null;
2381
+ }
2382
+ })();
2383
+ const response = await Promise.race([
2384
+ query,
2385
+ new Promise((resolve) => setTimeout(() => resolve(null), this.probeTimeoutMs))
2386
+ ]);
2387
+ const map = response?.data ?? response;
2388
+ const info = map?.[sessionID];
2389
+ return info?.type === "busy" || info?.type === "retry";
2390
+ }
2391
+ };
2392
+
2393
+ // lib/prune-tool.ts
2143
2394
  var PRUNE_TOOL_NAME = "dcp_prune";
2144
2395
  var PRUNE_TOOL_DESCRIPTION = `\u628A\u5F53\u524D\u4F1A\u8BDD\u7684\u65E7\u5BF9\u8BDD\u524D\u7F00\u6298\u53E0\u4E3A\u4E00\u4E2A\u6EDA\u52A8\u68C0\u67E5\u70B9\uFF08\u4FDD\u7559\u7CFB\u7EDF\u7EA7\u89C4\u5219\u3001\u538B\u7F29\u4E2D\u90E8\u5386\u53F2\u3001\u8BE6\u8FF0\u8FDB\u884C\u4E2D\u7684\u4EFB\u52A1\uFF09\uFF0C\u8FD1\u671F\u5C3E\u90E8\u4E0D\u53D7\u5F71\u54CD\u3002
2145
2396
 
@@ -2147,7 +2398,7 @@ var PRUNE_TOOL_DESCRIPTION = `\u628A\u5F53\u524D\u4F1A\u8BDD\u7684\u65E7\u5BF9\u
2147
2398
  - \u5BF9\u8BDD\u8BDD\u9898\u53D1\u751F\u660E\u663E\u53D8\u66F4\uFF1A\u5F00\u59CB\u5904\u7406\u65B0\u7684\u95EE\u9898\u57DF\u3001\u5207\u6362\u5230\u53E6\u4E00\u4E2A\u6A21\u5757/\u4ED3\u5E93/\u4EFB\u52A1\uFF1B
2148
2399
  - \u7528\u6237\u660E\u786E\u8981\u6C42\u538B\u7F29\u4E0A\u4E0B\u6587\u3002
2149
2400
 
2150
- \u540C\u4E00\u4EFB\u52A1\u5185\u7684\u591A\u8F6E\u8FFD\u95EE\u3001\u53C2\u6570\u5FAE\u8C03\u3001\u5EF6\u7EED\u5F53\u524D\u5DE5\u4F5C\uFF0C\u90FD\u4E0D\u8981\u8C03\u7528\u3002\u8C03\u7528\u4E0D\u4F1A\u6253\u65AD\u5F53\u524D\u5DE5\u4F5C\uFF1A\u538B\u7F29\u4F1A\u6392\u961F\uFF0C\u5E76\u5728\u4E0B\u4E00\u4E2A\u7A7A\u95F2\u8FB9\u754C\u5C1D\u8BD5\u6267\u884C\uFF1B\u5E76\u53D1\u8BF7\u6C42\u81EA\u52A8\u5408\u5E76\uFF0C\u5931\u8D25\u4E0D\u4F1A\u7834\u574F\u73B0\u6709\u4E0A\u4E0B\u6587\u3002\u4E0D\u8981\u4E3A\u540C\u4E00\u8BDD\u9898\u53CD\u590D\u8C03\u7528\u3002`;
2401
+ \u540C\u4E00\u4EFB\u52A1\u5185\u7684\u591A\u8F6E\u8FFD\u95EE\u3001\u53C2\u6570\u5FAE\u8C03\u3001\u5EF6\u7EED\u5F53\u524D\u5DE5\u4F5C\uFF0C\u90FD\u4E0D\u8981\u8C03\u7528\u3002\u8C03\u7528\u4E0D\u4F1A\u6253\u65AD\u5F53\u524D\u5DE5\u4F5C\uFF1A\u538B\u7F29\u4F1A\u6392\u961F\uFF0C\u5E76\u5728\u4E0B\u4E00\u4E2A\u786E\u8BA4\u7684\u9759\u606F\u8FB9\u754C\uFF08\u77ED\u6682\u9759\u9ED8\u7A97\u53E3\u52A0\u5B9E\u65F6\u72B6\u6001\u68C0\u67E5\uFF09\u5C1D\u8BD5\u6267\u884C\uFF1B\u5E76\u53D1\u8BF7\u6C42\u81EA\u52A8\u5408\u5E76\uFF0C\u5931\u8D25\u4E0D\u4F1A\u7834\u574F\u73B0\u6709\u4E0A\u4E0B\u6587\u3002\u4E0D\u8981\u4E3A\u540C\u4E00\u8BDD\u9898\u53CD\u590D\u8C03\u7528\u3002`;
2151
2402
  function createPruneTool(deps) {
2152
2403
  return tool({
2153
2404
  description: PRUNE_TOOL_DESCRIPTION,
@@ -2162,7 +2413,7 @@ function createPruneTool(deps) {
2162
2413
  return "DCP\uFF1A\u8BED\u4E49\u538B\u7F29\u5B8C\u6210\uFF0C\u65E7\u4E0A\u4E0B\u6587\u5DF2\u6298\u53E0\u4E3A\u65B0\u68C0\u67E5\u70B9\u3002";
2163
2414
  }
2164
2415
  if (result.status === "deferred") {
2165
- return "DCP\uFF1A\u4F1A\u8BDD\u4ECD\u5728\u5DE5\u4F5C\u4E2D\uFF0C\u538B\u7F29\u5DF2\u6392\u961F\uFF0C\u5C06\u5728\u4E0B\u4E00\u4E2A\u7A7A\u95F2\u8FB9\u754C\u5C1D\u8BD5\u81EA\u52A8\u6267\u884C\uFF1B\u5F53\u524D\u4E0A\u4E0B\u6587\u4E0D\u53D7\u5F71\u54CD\u3002";
2416
+ return "DCP\uFF1A\u4F1A\u8BDD\u4ECD\u5728\u5DE5\u4F5C\u4E2D\uFF0C\u538B\u7F29\u5DF2\u6392\u961F\uFF0C\u5C06\u5728\u4E0B\u4E00\u4E2A\u786E\u8BA4\u7684\u9759\u606F\u8FB9\u754C\u5C1D\u8BD5\u81EA\u52A8\u6267\u884C\uFF1B\u5F53\u524D\u4E0A\u4E0B\u6587\u4E0D\u53D7\u5F71\u54CD\u3002";
2166
2417
  }
2167
2418
  if (result.status === "busy") {
2168
2419
  return "DCP\uFF1A\u4F1A\u8BDD\u6B63\u5FD9\uFF0C\u4E3A\u907F\u514D\u6253\u65AD\u5F53\u524D\u5DE5\u4F5C\uFF0C\u672C\u6B21\u672A\u6267\u884C\u538B\u7F29\u3002";
@@ -2406,13 +2657,19 @@ var server = (async (ctx) => {
2406
2657
  const summarize = new SummarizeCoordinator(ctx.client, logger, {
2407
2658
  failureCooldownMs: config.summarize.failureCooldownMs
2408
2659
  });
2409
- const prune = new PruneService({
2410
- client: ctx.client,
2411
- summarize,
2412
- activity: new SessionActivityTracker(),
2413
- logger
2414
- });
2660
+ const prune = new PruneService({ client: ctx.client, summarize, logger });
2415
2661
  const autoPruner = new AutoPruner(config.autoPrune);
2662
+ if (config.autoPrune.enabled) {
2663
+ prune.boundary.onAtRest(
2664
+ createAtRestAutoPruneListener({
2665
+ client: ctx.client,
2666
+ prune,
2667
+ autoPruner,
2668
+ config: config.autoPrune,
2669
+ logger
2670
+ })
2671
+ );
2672
+ }
2416
2673
  logger.info("DCP initialized", {
2417
2674
  commands: config.commands.enabled,
2418
2675
  autoPrune: config.autoPrune.enabled,
@@ -2425,14 +2682,13 @@ var server = (async (ctx) => {
2425
2682
  ...config.autoPrune.enabled && {
2426
2683
  "chat.message": createChatMessageHandler(autoPruner)
2427
2684
  },
2428
- // The event feed drives both auto prune and the tool's deferred prunes,
2429
- // so it stays registered whenever either surface is on.
2685
+ // The event feed drives both the boundary classification (status/idle
2686
+ // observations) and the tool's deferred prunes, so it stays registered
2687
+ // whenever either surface is on.
2430
2688
  ...(config.autoPrune.enabled || config.tool.enabled) && {
2431
2689
  event: createEventHandler({
2432
- client: ctx.client,
2433
2690
  prune,
2434
2691
  autoPruner,
2435
- config: config.autoPrune,
2436
2692
  logger
2437
2693
  })
2438
2694
  },