context-mode 1.0.136 → 1.0.138

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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +9 -24
  7. package/build/adapters/codex/index.js +24 -3
  8. package/build/adapters/jetbrains-copilot/hooks.d.ts +11 -3
  9. package/build/adapters/jetbrains-copilot/hooks.js +11 -7
  10. package/build/adapters/opencode/index.d.ts +1 -0
  11. package/build/adapters/opencode/index.js +25 -0
  12. package/build/adapters/opencode/plugin.d.ts +22 -0
  13. package/build/adapters/opencode/plugin.js +52 -0
  14. package/build/adapters/pi/extension.js +20 -4
  15. package/build/adapters/pi/mcp-bridge.d.ts +2 -1
  16. package/build/adapters/pi/mcp-bridge.js +49 -3
  17. package/build/adapters/vscode-copilot/hooks.d.ts +27 -3
  18. package/build/adapters/vscode-copilot/hooks.js +27 -12
  19. package/build/cli.js +199 -32
  20. package/build/lifecycle.d.ts +2 -51
  21. package/build/lifecycle.js +3 -67
  22. package/build/openclaw-plugin.d.ts +130 -0
  23. package/build/openclaw-plugin.js +626 -0
  24. package/build/opencode-plugin.d.ts +122 -0
  25. package/build/opencode-plugin.js +372 -0
  26. package/build/pi-extension.d.ts +14 -0
  27. package/build/pi-extension.js +451 -0
  28. package/build/server.d.ts +19 -0
  29. package/build/server.js +145 -59
  30. package/build/session/db.d.ts +6 -0
  31. package/build/session/db.js +17 -3
  32. package/build/util/db-lock.d.ts +65 -0
  33. package/build/util/db-lock.js +166 -0
  34. package/build/util/sibling-mcp.d.ts +0 -40
  35. package/build/util/sibling-mcp.js +11 -116
  36. package/cli.bundle.mjs +181 -166
  37. package/configs/kilo/kilo.json +0 -11
  38. package/configs/opencode/opencode.json +0 -11
  39. package/hooks/normalize-hooks.mjs +101 -19
  40. package/hooks/session-db.bundle.mjs +3 -3
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +1 -1
  43. package/scripts/heal-installed-plugins.mjs +115 -1
  44. package/scripts/postinstall.mjs +16 -18
  45. package/server.bundle.mjs +112 -110
  46. package/start.mjs +11 -14
@@ -1,4 +1,3 @@
1
- import { buildNodeCommand } from "../types.js";
2
1
  /**
3
2
  * adapters/vscode-copilot/hooks — VS Code Copilot hook definitions and matchers.
4
3
  *
@@ -63,21 +62,37 @@ export function isContextModeHook(entry, hookType) {
63
62
  }
64
63
  /**
65
64
  * Build the hook command string for a given hook type.
66
- * Uses absolute node path to avoid PATH issues (homebrew, nvm, volta, etc.).
67
- * Falls back to CLI dispatcher if pluginRoot is not provided.
65
+ *
66
+ * Always emits the CLI dispatcher form
67
+ * (`context-mode hook vscode-copilot <event>`) — the `pluginRoot` argument
68
+ * is accepted for API compatibility but intentionally ignored.
69
+ *
70
+ * Why the dispatcher form is mandatory here (Issue #613 — Tier C contract):
71
+ * `.github/hooks/context-mode.json` is a **workspace-committed** file
72
+ * (upstream: refs/platforms/vscode-copilot/assets/prompts/skills/
73
+ * agent-customization/references/hooks.md line 7 — "Workspace
74
+ * (team-shared)"). It lands in every teammate's `git status`. Embedding
75
+ * `process.execPath` or any absolute pluginRoot path here:
76
+ * - Leaks PII (username, `C:/Users/<user>/...` paths).
77
+ * - Breaks cross-machine portability (fnm/nvm/volta/brew shims are
78
+ * per-shell-session ephemeral; the path goes stale immediately on
79
+ * Windows + fnm).
80
+ *
81
+ * Commit `f5c9d02` (2026-03-06) added an absolute-path branch when a
82
+ * pluginRoot was passed. It solved a real PATH-availability bug on
83
+ * Brew/nvm setups by going too far — the CLI then always passes
84
+ * pluginRoot, so the portable form became unreachable in production
85
+ * and every `/ctx-upgrade` baked a non-portable command into the
86
+ * committed config. This reverts to the pre-`f5c9d02` shape.
87
+ *
88
+ * For users without a global install, the recovery path is the same as
89
+ * every other CLI-dispatcher adapter (cursor, codex):
90
+ * `npm install -g context-mode`
68
91
  */
69
- export function buildHookCommand(hookType, pluginRoot) {
92
+ export function buildHookCommand(hookType, _pluginRoot) {
70
93
  const scriptName = HOOK_SCRIPTS[hookType];
71
94
  if (!scriptName) {
72
95
  throw new Error(`No script defined for hook type: ${hookType}`);
73
96
  }
74
- if (pluginRoot) {
75
- // v1.0.107 fix — was `${pluginRoot}/hooks/${scriptName}` which resolved to
76
- // the Claude-Code generic hook (`hooks/pretooluse.mjs`) instead of the
77
- // VSCode-specific wrapper at `hooks/vscode-copilot/pretooluse.mjs`. JetBrains
78
- // adapter already had the correct subdir (jetbrains-copilot/hooks.ts:98)
79
- // so this brings VSCode to parity.
80
- return buildNodeCommand(`${pluginRoot}/hooks/vscode-copilot/${scriptName}`);
81
- }
82
97
  return `context-mode hook vscode-copilot ${hookType.toLowerCase()}`;
83
98
  }
package/build/cli.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import * as p from "@clack/prompts";
15
15
  import color from "picocolors";
16
16
  import { execFileSync, execSync, execFile as nodeExecFile } from "node:child_process";
17
- import { readFileSync, writeFileSync, cpSync, accessSync, existsSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
17
+ import { readFileSync, cpSync, accessSync, existsSync, readdirSync, rmSync, closeSync, openSync, chmodSync, constants } from "node:fs";
18
18
  import { request as httpsRequest } from "node:https";
19
19
  import { resolve, dirname, join } from "node:path";
20
20
  import { tmpdir, devNull, homedir } from "node:os";
@@ -27,7 +27,7 @@ import { discoverSiblingMcpPids, killSiblingMcpServers } from "./util/sibling-mc
27
27
  // v1.0.119 — Issue #523 Layer 5 heal: post-bump assertion on .claude-plugin/plugin.json
28
28
  // mcpServers args. Single source of truth shared with start.mjs HEAL block + postinstall.
29
29
  // @ts-expect-error — JS module, no TS declarations
30
- import { healPluginJsonMcpServers, healMcpJsonArgs } from "../scripts/heal-installed-plugins.mjs";
30
+ import { healPluginJsonMcpServers, sweepStaleMcpJson } from "../scripts/heal-installed-plugins.mjs";
31
31
  // @ts-expect-error — JS module, no TS declarations
32
32
  import { detectWindowsVsYear } from "../scripts/heal-better-sqlite3.mjs";
33
33
  // Private 16-LOC copy of browserOpenArgv. Canonical version lives in src/server.ts;
@@ -454,6 +454,166 @@ async function doctor() {
454
454
  p.log.warn(color.yellow("Plugin enabled: WARN") +
455
455
  ` — ${pluginCheck.message}`);
456
456
  }
457
+ // ── Issue #613 — proactive Tier C absolute-path detection ───────────
458
+ // PR #620 fixed `buildHookCommand` for vscode-copilot + jetbrains-copilot
459
+ // so future writes are CLI-dispatcher-shape. But users who ran
460
+ // /ctx-upgrade on v1.0.136 or earlier are still carrying poisoned
461
+ // committable files in their workspace:
462
+ // - `.github/hooks/context-mode.json` (vscode-copilot, team-shared)
463
+ // - `.jetbrains/copilot/hooks.json` (jetbrains-copilot, team-shared)
464
+ // - `.cursor/hooks.json` (cursor, team-shared)
465
+ // Per ISSUE-613-VERDICT §6.1 these are Tier C — workspace-committed
466
+ // cross-machine config. Doctor scans them for absolute paths and
467
+ // fnm_multishells shims; if found, FAIL with `ctx_upgrade` remediation.
468
+ // Per ISSUE-604-VERDICT §11 ("silent-green doctor while hooks are dead
469
+ // is itself a P0 trust bug") — surface poison BEFORE the user hits a
470
+ // runtime failure.
471
+ p.log.step("Checking team-shared hook configs in your workspace...");
472
+ {
473
+ const projectDir = process.cwd();
474
+ const tierCFiles = [
475
+ ".github/hooks/context-mode.json",
476
+ ".cursor/hooks.json",
477
+ ".jetbrains/copilot/hooks.json",
478
+ ];
479
+ let tierCFails = 0;
480
+ let tierCChecked = 0;
481
+ // Detect absolute-path patterns that should never appear in a
482
+ // workspace-committed config. Per Mert's standing Windows-safety rule:
483
+ // handle both `/` and `\\` separators.
484
+ function isAbsoluteOrShimPath(s) {
485
+ // unix absolute
486
+ if (s.startsWith("/"))
487
+ return true;
488
+ // Windows drive-letter absolute (e.g. C:/, C:\)
489
+ if (/^[A-Za-z]:[/\\]/.test(s))
490
+ return true;
491
+ // Windows UNC or escaped-backslash absolute fragments
492
+ if (s.includes("\\\\"))
493
+ return true;
494
+ // fnm shim hint — issue #613 reporter's exact stderr shape
495
+ if (s.includes("fnm_multishells"))
496
+ return true;
497
+ // process.execPath literal baked into JSON
498
+ if (s.includes("process.execPath"))
499
+ return true;
500
+ return false;
501
+ }
502
+ function recurseStrings(node, hit) {
503
+ if (typeof node === "string") {
504
+ hit(node);
505
+ }
506
+ else if (Array.isArray(node)) {
507
+ for (const item of node)
508
+ recurseStrings(item, hit);
509
+ }
510
+ else if (node && typeof node === "object") {
511
+ for (const v of Object.values(node))
512
+ recurseStrings(v, hit);
513
+ }
514
+ }
515
+ for (const rel of tierCFiles) {
516
+ const abs = resolve(projectDir, rel);
517
+ if (!existsSync(abs))
518
+ continue; // missing config → SKIP, no false fail
519
+ tierCChecked++;
520
+ try {
521
+ const parsed = JSON.parse(readFileSync(abs, "utf-8"));
522
+ const offenders = [];
523
+ recurseStrings(parsed, (s) => {
524
+ if (isAbsoluteOrShimPath(s))
525
+ offenders.push(s);
526
+ });
527
+ if (offenders.length > 0) {
528
+ criticalFails++;
529
+ tierCFails++;
530
+ // Truncate to one example to keep output readable; show count.
531
+ const example = offenders[0].length > 100
532
+ ? offenders[0].slice(0, 97) + "..."
533
+ : offenders[0];
534
+ p.log.error(color.red(`Hook config: FAIL`) +
535
+ ` — ${rel} has your machine's local paths baked in` +
536
+ color.dim("\n This file is committed to git, so teammates and CI will get your path and the hooks will break for them." +
537
+ `\n Found ${offenders.length} hard-coded path(s), e.g.: ${example}` +
538
+ "\n Fix: run /context-mode:ctx-upgrade — it rewrites the file to a portable form that works on every machine." +
539
+ "\n Details: https://github.com/mksglu/context-mode/issues/613"));
540
+ }
541
+ else {
542
+ p.log.success(color.green("Hook config: PASS") +
543
+ color.dim(` — ${rel} is portable (no hard-coded paths)`));
544
+ }
545
+ }
546
+ catch (err) {
547
+ // Malformed JSON should not crash doctor; warn and move on.
548
+ const msg = err instanceof Error ? err.message : String(err);
549
+ p.log.warn(color.yellow(`Hook config: WARN`) +
550
+ ` — ${rel} is not valid JSON` +
551
+ color.dim("\n Doctor cannot scan it for portability issues until the file parses." +
552
+ "\n Fix: open the file and check it in a JSON validator, or delete it and run /context-mode:ctx-upgrade to regenerate." +
553
+ `\n Parser said: ${msg.slice(0, 160)}`));
554
+ }
555
+ }
556
+ if (tierCChecked === 0) {
557
+ p.log.info(color.dim("Hook config: SKIP — no team-shared hook configs found in this workspace"));
558
+ }
559
+ else if (tierCFails === 0) {
560
+ // already individual PASS messages above; no need for a summary
561
+ }
562
+ }
563
+ // ── Issue #609 — proactive stale `.mcp.json` detection ──────────────
564
+ // PR #620 deleted the per-version cache `.mcp.json` write from cli.ts
565
+ // and shipped `sweepStaleMcpJson` to clean up any pre-existing copies.
566
+ // But users on the field may still have stale `.mcp.json` files left
567
+ // by /ctx-upgrade flows that ran before PR #620 (or by Claude Code's
568
+ // native auto-update copying a poisoned file forward). Surface those
569
+ // as WARN (recoverable — next ctx_upgrade sweeps them) so the user
570
+ // knows what to do instead of being told everything is green while
571
+ // the file lingers on disk.
572
+ // Per ISSUE-604-VERDICT §11 same trust contract as Tier C check above.
573
+ p.log.step("Checking for leftover .mcp.json files from older versions...");
574
+ {
575
+ const cacheRoot = join(homedir(), ".claude", "plugins", "cache", "context-mode", "context-mode");
576
+ if (!existsSync(cacheRoot)) {
577
+ p.log.info(color.dim("Leftover .mcp.json check: SKIP — no plugin cache exists yet (Claude Code has not installed context-mode here)"));
578
+ }
579
+ else {
580
+ let staleCount = 0;
581
+ const staleVersions = [];
582
+ try {
583
+ const versionDirs = readdirSync(cacheRoot);
584
+ for (const v of versionDirs) {
585
+ const candidate = join(cacheRoot, v, ".mcp.json");
586
+ if (existsSync(candidate)) {
587
+ staleCount++;
588
+ if (staleVersions.length < 5)
589
+ staleVersions.push(v);
590
+ }
591
+ }
592
+ }
593
+ catch (err) {
594
+ const msg = err instanceof Error ? err.message : String(err);
595
+ p.log.warn(color.yellow("Leftover .mcp.json check: WARN") +
596
+ ` — could not read the plugin cache directory` +
597
+ color.dim(`\n Path: ${cacheRoot}` +
598
+ `\n Reason: ${msg.slice(0, 160)}` +
599
+ "\n Fix: check that the directory is readable, then re-run doctor. If the issue persists, run /context-mode:ctx-upgrade."));
600
+ staleCount = 0;
601
+ }
602
+ if (staleCount === 0) {
603
+ p.log.success(color.green("Leftover .mcp.json check: PASS") +
604
+ color.dim(" — no old .mcp.json files in the plugin cache"));
605
+ }
606
+ else {
607
+ // WARN, not FAIL — per architect spec this is recoverable.
608
+ p.log.warn(color.yellow("Leftover .mcp.json check: WARN") +
609
+ ` — found ${staleCount} old .mcp.json file(s) left over from previous context-mode versions` +
610
+ color.dim("\n These are harmless but should be cleaned up so they cannot confuse Claude Code after an auto-update." +
611
+ `\n Versions affected: ${staleVersions.join(", ")}${staleCount > staleVersions.length ? ", ..." : ""}` +
612
+ "\n Fix: run /context-mode:ctx-upgrade — it sweeps these files automatically on the next run." +
613
+ "\n Details: https://github.com/mksglu/context-mode/issues/609"));
614
+ }
615
+ }
616
+ }
457
617
  // FTS5 / SQLite
458
618
  p.log.step("Checking FTS5 / SQLite...");
459
619
  try {
@@ -798,20 +958,24 @@ async function upgrade(opts) {
798
958
  }
799
959
  catch { /* some files may not exist in source */ }
800
960
  }
801
- // Write .mcp.json with CLAUDE_PLUGIN_ROOT placeholder (fixes #411).
802
- // Absolute paths bake-in the current pluginRoot dir, which sessionstart.mjs
803
- // (#181) deletes after upgrade breaking MCP server resolution. The literal
804
- // ${CLAUDE_PLUGIN_ROOT} placeholder is resolved by Claude at load-time and
805
- // stays valid across version cleanups. Matches .claude-plugin/plugin.json.
806
- const mcpConfig = {
807
- mcpServers: {
808
- "context-mode": {
809
- command: "node",
810
- args: ["${CLAUDE_PLUGIN_ROOT}/start.mjs"],
811
- },
812
- },
813
- };
814
- writeFileSync(resolve(pluginRoot, ".mcp.json"), JSON.stringify(mcpConfig, null, 2) + "\n");
961
+ // Issue #609 — DO NOT write `.mcp.json` into the plugin cache dir.
962
+ //
963
+ // Historical context: #411 fixed an absolute-path bake by writing the
964
+ // ${CLAUDE_PLUGIN_ROOT} placeholder form here. #531 (commit 9261377)
965
+ // removed `.mcp.json` from `package.json files[]` so the npm tarball
966
+ // stopped shipping it. But the cli-side write persisted, so every
967
+ // /ctx-upgrade re-baked one. When Claude Code's native plugin manager
968
+ // auto-update later carries a previous version's `.mcp.json` forward
969
+ // into a fresh version dir, the stale start.mjs absolute path goes
970
+ // with it → MODULE_NOT_FOUND on every MCP boot.
971
+ //
972
+ // Architectural fix: Claude Code reads `.claude-plugin/plugin.json`
973
+ // .mcpServers as the canonical source (upstream:
974
+ // refs/platforms/claude-code/src/utils/plugins/mcpPluginIntegration.ts:131-212).
975
+ // `.mcp.json` is a redundant per-version artifact whose only role
976
+ // historically was to be a write-time poison vector. Don't write it.
977
+ // The post-bump cache-sweep below removes any pre-existing copies so
978
+ // the previous-version-carry vector cannot replay.
815
979
  // Normalize hooks.json + plugin.json against the REAL pluginRoot now that
816
980
  // files have been copied. Two reasons:
817
981
  // 1. If a prior buggy postinstall (or any future regression) baked the
@@ -908,30 +1072,33 @@ async function upgrade(opts) {
908
1072
  const message = err instanceof Error ? err.message : String(err);
909
1073
  throw new Error(`plugin.json drift check failed: ${message}`);
910
1074
  }
911
- // v1.0.122 — Issue #531 — Layer 6 heal: assert .mcp.json's
912
- // mcpServers["context-mode"].args[0] is the literal ${CLAUDE_PLUGIN_ROOT}/start.mjs
913
- // placeholder. Asymmetric-heal sibling of the plugin.json assertion above.
914
- // cli.ts writes .mcp.json at ~line 829-845 with the placeholder, but never
915
- // asserted the on-disk shape afterwards if a future regression dropped
916
- // the placeholder write or a parallel normalize baked in an absolute path,
917
- // upgrade() would declare success on a poisoned tree. Belt-and-braces:
918
- // first call cleans any drift; second call MUST return healed:[] or throw.
919
- // Single source of truth shared with start.mjs HEAL block + postinstall.
1075
+ // Issue #609 — Layer 6 replacement: sweep stale `.mcp.json` files from
1076
+ // every per-version cache dir. Supersedes the previous healMcpJsonArgs
1077
+ // drift-check block (v1.0.122) that block existed because cli.ts
1078
+ // itself wrote `.mcp.json`. With the write gone (above), the only
1079
+ // remaining `.mcp.json` files are stale carry-forwards from earlier
1080
+ // versions. Sweep them so Claude Code's auto-update can't replay them
1081
+ // into a fresh version dir.
1082
+ //
1083
+ // Belt-and-braces: a second sweep call MUST report removed:[] or we
1084
+ // throw — same architectural-lock pattern as the plugin.json drift
1085
+ // check above. Single source of truth shared with start.mjs HEAL
1086
+ // block + postinstall.
920
1087
  try {
921
1088
  const pluginCacheRoot = resolve(resolveClaudeConfigDir(), "plugins", "cache");
922
1089
  const pluginKey = "context-mode@context-mode";
923
- const firstPass = healMcpJsonArgs({ pluginRoot, pluginCacheRoot, pluginKey });
924
- if (firstPass && firstPass.error) {
925
- throw new Error(firstPass.error);
1090
+ const firstSweep = sweepStaleMcpJson({ pluginCacheRoot, pluginKey });
1091
+ if (firstSweep && firstSweep.removed && firstSweep.removed.length > 0) {
1092
+ p.log.info(color.dim(` Swept ${firstSweep.removed.length} stale .mcp.json file(s) from cache`));
926
1093
  }
927
- const secondPass = healMcpJsonArgs({ pluginRoot, pluginCacheRoot, pluginKey });
928
- if (secondPass && Array.isArray(secondPass.healed) && secondPass.healed.length > 0) {
929
- throw new Error(`.mcp.json drift: mcpServers.args still poisoned after first heal pass (healed=${secondPass.healed.join(",")})`);
1094
+ const secondSweep = sweepStaleMcpJson({ pluginCacheRoot, pluginKey });
1095
+ if (secondSweep && Array.isArray(secondSweep.removed) && secondSweep.removed.length > 0) {
1096
+ throw new Error(`.mcp.json sweep drift: ${secondSweep.removed.length} file(s) still present after first pass`);
930
1097
  }
931
1098
  }
932
1099
  catch (err) {
933
1100
  const message = err instanceof Error ? err.message : String(err);
934
- throw new Error(`.mcp.json drift check failed: ${message}`);
1101
+ throw new Error(`.mcp.json sweep check failed: ${message}`);
935
1102
  }
936
1103
  // v1.0.X — Layer 7 heal: update user-level ~/.claude.json MCP server
937
1104
  // registrations that point to old context-mode version dirs.
@@ -20,54 +20,7 @@ export interface LifecycleGuardOptions {
20
20
  onShutdown: () => void;
21
21
  /** Injectable parent-alive check (for testing). Default: ppid-based check. */
22
22
  isParentAlive?: () => boolean;
23
- /**
24
- * Idle shutdown threshold in ms (#565). When the server has handled no
25
- * MCP activity for this long, `onShutdown` fires. `0` disables.
26
- * Default: env `CONTEXT_MODE_IDLE_TIMEOUT_MS`, else 0 (disabled).
27
- * Skipped on TTY stdin (interactive dev / OpenCode ts-plugin standalone).
28
- *
29
- * Pair with the returned `recordActivity()` callback — call it on every
30
- * MCP request the server handles so genuinely busy servers never trip.
31
- */
32
- idleTimeoutMs?: number;
33
- /** Test injection — defaults to `Date.now`. */
34
- now?: () => number;
35
23
  }
36
- /**
37
- * Hybrid return type: callable like the original `() => void` cleanup (kept
38
- * for backwards compatibility with #103/#236/#311/#388/#534 test suites),
39
- * and additionally exposes `recordActivity` for the idle-timeout path (#565)
40
- * and `stop` as an explicit alias.
41
- */
42
- export interface LifecycleGuardHandle {
43
- /** Stop the guard. Calling the handle directly is equivalent. */
44
- (): void;
45
- /** Bumps the "last activity" timestamp so the idle timer doesn't fire. */
46
- recordActivity: () => void;
47
- /** Stop the guard. Alias for invoking the handle. */
48
- stop: () => void;
49
- }
50
- /**
51
- * Resolve the idle-shutdown threshold (#565).
52
- *
53
- * Idle shutdown is OFF by default (#592) because most hosts (Claude
54
- * Code, Codex, editor MCP clients) keep registered tool handles after a
55
- * clean MCP child exit and do NOT transparently respawn on the next call.
56
- * The global 15 min default introduced in #568 solved OpenCode's child
57
- * accumulation, but stranded ctx_* tools in Claude Code/Codex-style
58
- * hosts once the MCP server exited cleanly while the editor stayed alive.
59
- *
60
- * Hosts that are known to benefit from idle shutdown MUST opt in via
61
- * CONTEXT_MODE_IDLE_TIMEOUT_MS in their MCP config. Today that is
62
- * OpenCode/KiloCode (their configs set 900000 = 15 min). Users and test
63
- * harnesses can also opt in explicitly with any positive integer.
64
- *
65
- * Missing or malformed env = 0 (disabled, safe default). Set env to
66
- * `0` to disable explicitly.
67
- *
68
- * Exported for unit-testing.
69
- */
70
- export declare function idleTimeoutForEnv(env?: NodeJS.ProcessEnv): number;
71
24
  /** Injectable dependencies for {@link makeDefaultIsParentAlive}. */
72
25
  export interface IsParentAliveDeps {
73
26
  /** Read the current ppid. Default: `() => process.ppid`. */
@@ -107,9 +60,7 @@ export declare function makeDefaultIsParentAlive(deps?: IsParentAliveDeps): () =
107
60
  */
108
61
  export declare function lifecycleGuardIntervalForEnv(env?: NodeJS.ProcessEnv): number;
109
62
  /**
110
- * Start the lifecycle guard. Returns a handle with `recordActivity` (call
111
- * on every MCP request to keep idle timer from firing) and `stop`.
112
- *
63
+ * Start the lifecycle guard. Returns a cleanup function.
113
64
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
114
65
  */
115
- export declare function startLifecycleGuard(opts: LifecycleGuardOptions): LifecycleGuardHandle;
66
+ export declare function startLifecycleGuard(opts: LifecycleGuardOptions): () => void;
@@ -14,35 +14,6 @@
14
14
  * Cross-platform: macOS, Linux, Windows.
15
15
  */
16
16
  import { execFileSync } from "node:child_process";
17
- /**
18
- * Resolve the idle-shutdown threshold (#565).
19
- *
20
- * Idle shutdown is OFF by default (#592) because most hosts (Claude
21
- * Code, Codex, editor MCP clients) keep registered tool handles after a
22
- * clean MCP child exit and do NOT transparently respawn on the next call.
23
- * The global 15 min default introduced in #568 solved OpenCode's child
24
- * accumulation, but stranded ctx_* tools in Claude Code/Codex-style
25
- * hosts once the MCP server exited cleanly while the editor stayed alive.
26
- *
27
- * Hosts that are known to benefit from idle shutdown MUST opt in via
28
- * CONTEXT_MODE_IDLE_TIMEOUT_MS in their MCP config. Today that is
29
- * OpenCode/KiloCode (their configs set 900000 = 15 min). Users and test
30
- * harnesses can also opt in explicitly with any positive integer.
31
- *
32
- * Missing or malformed env = 0 (disabled, safe default). Set env to
33
- * `0` to disable explicitly.
34
- *
35
- * Exported for unit-testing.
36
- */
37
- export function idleTimeoutForEnv(env = process.env) {
38
- const raw = env.CONTEXT_MODE_IDLE_TIMEOUT_MS;
39
- if (raw === undefined)
40
- return 0;
41
- const n = Number.parseInt(raw, 10);
42
- if (!Number.isFinite(n) || n < 0)
43
- return 0;
44
- return n;
45
- }
46
17
  /** Read grandparent PID via `ps -o ppid= -p $PPID`. Returns NaN on failure or Windows. */
47
18
  function readGrandparentPpidImpl() {
48
19
  if (process.platform === "win32")
@@ -124,52 +95,25 @@ export function lifecycleGuardIntervalForEnv(env = process.env) {
124
95
  return 1000;
125
96
  }
126
97
  /**
127
- * Start the lifecycle guard. Returns a handle with `recordActivity` (call
128
- * on every MCP request to keep idle timer from firing) and `stop`.
129
- *
98
+ * Start the lifecycle guard. Returns a cleanup function.
130
99
  * Skipped automatically when stdin is a TTY (e.g. OpenCode ts-plugin).
131
100
  */
132
101
  export function startLifecycleGuard(opts) {
133
102
  const interval = opts.checkIntervalMs ?? lifecycleGuardIntervalForEnv();
134
103
  const check = opts.isParentAlive ?? defaultIsParentAlive;
135
- const idleTimeoutMs = opts.idleTimeoutMs ?? idleTimeoutForEnv();
136
- const now = opts.now ?? Date.now;
137
104
  let stopped = false;
138
- let lastActivity = now();
139
105
  const shutdown = () => {
140
106
  if (stopped)
141
107
  return;
142
108
  stopped = true;
143
109
  opts.onShutdown();
144
110
  };
145
- const recordActivity = () => {
146
- lastActivity = now();
147
- };
148
- // P0: Periodic parent liveness check.
111
+ // P0: Periodic parent liveness check
149
112
  const timer = setInterval(() => {
150
113
  if (!check())
151
114
  shutdown();
152
115
  }, interval);
153
116
  timer.unref();
154
- // P0+: Idle shutdown (#565). Runs on its OWN tick — distinct from the
155
- // 30 s parent-liveness poll — so a 15 min idle timeout actually reacts
156
- // close to 15 min instead of "next 30 s tick after 15 min". Pick the
157
- // tick as min(idleTimeoutMs / 6, 30 s) so a short timeout (e.g. 3 s in
158
- // e2e tests, 60 s in dev) reacts within ~16 % of its window while a
159
- // production 15 min timeout still polls every 30 s (cheap).
160
- //
161
- // Skipped on TTY because interactive dev sessions are expected to
162
- // sit idle between commands, and also when idleTimeoutMs is 0 (env
163
- // opt-out via CONTEXT_MODE_IDLE_TIMEOUT_MS=0).
164
- let idleTimer = null;
165
- if (idleTimeoutMs > 0 && !process.stdin.isTTY) {
166
- const idleTick = Math.max(50, Math.min(Math.floor(idleTimeoutMs / 6), 30_000));
167
- idleTimer = setInterval(() => {
168
- if (now() - lastActivity > idleTimeoutMs)
169
- shutdown();
170
- }, idleTick);
171
- idleTimer.unref();
172
- }
173
117
  // P0: OS signals — terminal close, kill, ctrl+c
174
118
  const signals = ["SIGTERM", "SIGINT"];
175
119
  if (process.platform !== "win32")
@@ -198,19 +142,11 @@ export function startLifecycleGuard(opts) {
198
142
  if (!process.stdin.isTTY) {
199
143
  process.stdin.on("end", onStdinEnd);
200
144
  }
201
- const cleanup = () => {
145
+ return () => {
202
146
  stopped = true;
203
147
  clearInterval(timer);
204
- if (idleTimer)
205
- clearInterval(idleTimer);
206
148
  for (const sig of signals)
207
149
  process.removeListener(sig, shutdown);
208
150
  process.stdin.removeListener("end", onStdinEnd);
209
151
  };
210
- // Hybrid: callable for legacy `const cleanup = startLifecycleGuard(...)`
211
- // sites, with `.recordActivity` / `.stop` properties for the new contract.
212
- const handle = cleanup;
213
- handle.recordActivity = recordActivity;
214
- handle.stop = cleanup;
215
- return handle;
216
152
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * OpenClaw TypeScript plugin entry point for context-mode.
3
+ *
4
+ * Exports an object with { id, name, configSchema, register(api) } for
5
+ * declarative metadata and config validation before code execution.
6
+ *
7
+ * register(api) registers:
8
+ * - before_tool_call hook — Routing enforcement (deny/modify/passthrough)
9
+ * - after_tool_call hook — Session event capture
10
+ * - command:new hook — Session initialization and cleanup
11
+ * - session_start hook — Re-key DB session to OpenClaw's session ID
12
+ * - before_compaction hook — Flush events to resume snapshot
13
+ * - after_compaction hook — Increment compact count
14
+ * - before_prompt_build (p=10) — Resume snapshot injection into system context
15
+ * - before_prompt_build (p=5) — Routing instruction injection into system context
16
+ * - context-mode engine — Context engine with compaction management
17
+ * - /ctx-stats command — Auto-reply command for session statistics
18
+ * - /ctx-doctor command — Auto-reply command for diagnostics
19
+ * - /ctx-upgrade command — Auto-reply command for upgrade
20
+ *
21
+ * Loaded by OpenClaw via: openclaw.extensions entry in package.json
22
+ *
23
+ * OpenClaw plugin paradigm:
24
+ * - Plugins export { id, name, configSchema, register(api) } for metadata
25
+ * - api.registerHook() for event-driven hooks
26
+ * - api.on() for typed lifecycle hooks
27
+ * - api.registerContextEngine() for compaction ownership
28
+ * - api.registerCommand() for auto-reply slash commands
29
+ * - Plugins run in-process with the Gateway (trusted code)
30
+ */
31
+ import type { OpenClawToolDef } from "./openclaw/mcp-tools.js";
32
+ /** Context for auto-reply command handlers. */
33
+ interface CommandContext {
34
+ senderId?: string;
35
+ channel?: string;
36
+ isAuthorizedSender?: boolean;
37
+ args?: string;
38
+ commandBody?: string;
39
+ config?: Record<string, unknown>;
40
+ }
41
+ /** OpenClaw plugin API provided to the register function. */
42
+ interface OpenClawPluginApi {
43
+ registerHook(event: string, handler: (...args: unknown[]) => unknown, meta: {
44
+ name: string;
45
+ description: string;
46
+ }): void;
47
+ /**
48
+ * Register a typed lifecycle hook.
49
+ * Supported names: "session_start", "before_compaction", "after_compaction",
50
+ * "before_prompt_build"
51
+ */
52
+ on(event: string, handler: (...args: unknown[]) => unknown, opts?: {
53
+ priority?: number;
54
+ }): void;
55
+ registerContextEngine(id: string, factory: () => ContextEngineInstance): void;
56
+ registerCommand?(cmd: {
57
+ name: string;
58
+ description: string;
59
+ acceptsArgs?: boolean;
60
+ requireAuth?: boolean;
61
+ handler: (ctx: CommandContext) => {
62
+ text: string;
63
+ } | Promise<{
64
+ text: string;
65
+ }>;
66
+ }): void;
67
+ registerCli?(factory: (ctx: {
68
+ program: unknown;
69
+ }) => void, meta: {
70
+ commands: string[];
71
+ }): void;
72
+ /**
73
+ * Register an agent tool (OpenClaw native registerTool) — see
74
+ * refs/platforms/openclaw/docs/plugins/building-plugins.md:116. Optional in
75
+ * the type so we degrade silently on legacy hosts that pre-date this API.
76
+ */
77
+ registerTool?(tool: OpenClawToolDef, opts?: {
78
+ optional?: boolean;
79
+ }): void;
80
+ logger?: {
81
+ info: (...args: unknown[]) => void;
82
+ error: (...args: unknown[]) => void;
83
+ debug?: (...args: unknown[]) => void;
84
+ warn?: (...args: unknown[]) => void;
85
+ };
86
+ }
87
+ /** Context engine instance returned by the factory. */
88
+ interface ContextEngineInstance {
89
+ info: {
90
+ id: string;
91
+ name: string;
92
+ ownsCompaction: boolean;
93
+ };
94
+ ingest(data: unknown): Promise<{
95
+ ingested: boolean;
96
+ }>;
97
+ assemble(ctx: {
98
+ messages: unknown[];
99
+ }): Promise<{
100
+ messages: unknown[];
101
+ estimatedTokens: number;
102
+ }>;
103
+ compact(): Promise<{
104
+ ok: boolean;
105
+ compacted: boolean;
106
+ }>;
107
+ }
108
+ /**
109
+ * OpenClaw plugin definition. The object form provides declarative metadata
110
+ * (id, name, configSchema) that OpenClaw can read without executing code.
111
+ * register() is called once per agent session with a fresh api object.
112
+ * Each call creates isolated closures (db, sessionId, hooks) — no shared state.
113
+ */
114
+ declare const _default: {
115
+ id: string;
116
+ name: string;
117
+ configSchema: {
118
+ type: "object";
119
+ properties: {
120
+ enabled: {
121
+ type: "boolean";
122
+ default: boolean;
123
+ description: string;
124
+ };
125
+ };
126
+ additionalProperties: boolean;
127
+ };
128
+ register(api: OpenClawPluginApi): void;
129
+ };
130
+ export default _default;