context-mode 0.9.21 → 1.0.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/.claude-plugin/hooks/hooks.json +46 -4
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -4
- package/README.md +377 -191
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +53 -0
- package/build/adapters/claude-code/hooks.js +88 -0
- package/build/adapters/claude-code/index.d.ts +50 -0
- package/build/adapters/claude-code/index.js +523 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +21 -0
- package/build/adapters/codex/hooks.js +27 -0
- package/build/adapters/codex/index.d.ts +44 -0
- package/build/adapters/codex/index.js +223 -0
- package/build/adapters/detect.d.ts +26 -0
- package/build/adapters/detect.js +131 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +44 -0
- package/build/adapters/gemini-cli/hooks.js +64 -0
- package/build/adapters/gemini-cli/index.d.ts +57 -0
- package/build/adapters/gemini-cli/index.js +468 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +52 -0
- package/build/adapters/opencode/index.js +386 -0
- package/build/adapters/types.d.ts +218 -0
- package/build/adapters/types.js +13 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +49 -0
- package/build/adapters/vscode-copilot/hooks.js +76 -0
- package/build/adapters/vscode-copilot/index.d.ts +58 -0
- package/build/adapters/vscode-copilot/index.js +512 -0
- package/build/cli.d.ts +9 -6
- package/build/cli.js +133 -423
- package/build/db-base.d.ts +84 -0
- package/build/db-base.js +128 -0
- package/build/executor.d.ts +6 -7
- package/build/executor.js +111 -51
- package/build/opencode-plugin.d.ts +37 -0
- package/build/opencode-plugin.js +118 -0
- package/build/runtime.js +1 -1
- package/build/server.js +436 -117
- package/build/session/db.d.ts +110 -0
- package/build/session/db.js +285 -0
- package/build/session/extract.d.ts +51 -0
- package/build/session/extract.js +407 -0
- package/build/session/snapshot.d.ts +70 -0
- package/build/session/snapshot.js +309 -0
- package/build/store.d.ts +4 -22
- package/build/store.js +67 -55
- package/build/truncate.d.ts +59 -0
- package/build/truncate.js +157 -0
- package/build/types.d.ts +101 -0
- package/build/types.js +20 -0
- package/configs/claude-code/CLAUDE.md +62 -0
- package/configs/codex/AGENTS.md +58 -0
- package/configs/codex/config.toml +5 -0
- package/configs/gemini-cli/GEMINI.md +58 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +49 -0
- package/configs/opencode/AGENTS.md +58 -0
- package/configs/opencode/opencode.json +10 -0
- package/configs/vscode-copilot/copilot-instructions.md +58 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +8 -0
- package/hooks/core/formatters.mjs +86 -0
- package/hooks/core/routing.mjs +262 -0
- package/hooks/core/stdin.mjs +19 -0
- package/hooks/formatters/claude-code.mjs +57 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftertool.mjs +58 -0
- package/hooks/gemini-cli/beforetool.mjs +25 -0
- package/hooks/gemini-cli/precompress.mjs +51 -0
- package/hooks/gemini-cli/sessionstart.mjs +117 -0
- package/hooks/hooks.json +46 -4
- package/hooks/posttooluse.mjs +53 -0
- package/hooks/precompact.mjs +55 -0
- package/hooks/pretooluse.mjs +23 -266
- package/hooks/routing-block.mjs +19 -6
- package/hooks/session-directive.mjs +353 -0
- package/hooks/session-helpers.mjs +112 -0
- package/hooks/sessionstart.mjs +123 -16
- package/hooks/userpromptsubmit.mjs +58 -0
- package/hooks/vscode-copilot/posttooluse.mjs +58 -0
- package/hooks/vscode-copilot/precompact.mjs +51 -0
- package/hooks/vscode-copilot/pretooluse.mjs +25 -0
- package/hooks/vscode-copilot/sessionstart.mjs +115 -0
- package/package.json +20 -17
- package/skills/context-mode/SKILL.md +49 -49
- package/skills/{doctor → ctx-doctor}/SKILL.md +3 -3
- package/skills/{stats → ctx-stats}/SKILL.md +3 -3
- package/skills/{upgrade → ctx-upgrade}/SKILL.md +3 -3
- package/start.mjs +47 -0
- package/hooks/pretooluse.sh +0 -147
- package/server.bundle.mjs +0 -341
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-base — Reusable SQLite infrastructure for context-mode packages.
|
|
3
|
+
*
|
|
4
|
+
* Provides lazy-loading of better-sqlite3, WAL pragma setup, prepared
|
|
5
|
+
* statement caching interface, and DB file cleanup helpers. Both
|
|
6
|
+
* ContentStore and SessionDB build on top of these primitives.
|
|
7
|
+
*/
|
|
8
|
+
import type DatabaseConstructor from "better-sqlite3";
|
|
9
|
+
import type { Database as DatabaseInstance } from "better-sqlite3";
|
|
10
|
+
/**
|
|
11
|
+
* Explicit interface for cached prepared statements that accept varying
|
|
12
|
+
* parameter counts. better-sqlite3's generic `Statement` collapses under
|
|
13
|
+
* `ReturnType` to a single-param signature, so we define our own.
|
|
14
|
+
*/
|
|
15
|
+
export interface PreparedStatement {
|
|
16
|
+
run(...params: unknown[]): {
|
|
17
|
+
changes: number;
|
|
18
|
+
lastInsertRowid: number | bigint;
|
|
19
|
+
};
|
|
20
|
+
get(...params: unknown[]): unknown;
|
|
21
|
+
all(...params: unknown[]): unknown[];
|
|
22
|
+
iterate(...params: unknown[]): IterableIterator<unknown>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Lazy-load better-sqlite3. Only resolves the native module on first call.
|
|
26
|
+
* This allows the MCP server to start instantly even when the native addon
|
|
27
|
+
* is not yet installed (marketplace first-run scenario).
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadDatabase(): typeof DatabaseConstructor;
|
|
30
|
+
/**
|
|
31
|
+
* Apply WAL mode and NORMAL synchronous pragma to a database instance.
|
|
32
|
+
* Should be called immediately after opening a new database connection.
|
|
33
|
+
*
|
|
34
|
+
* WAL mode provides:
|
|
35
|
+
* - Concurrent readers while a write is in progress
|
|
36
|
+
* - Dramatically faster writes (no full-page sync on each commit)
|
|
37
|
+
* NORMAL synchronous is safe under WAL and avoids an extra fsync per
|
|
38
|
+
* transaction.
|
|
39
|
+
*/
|
|
40
|
+
export declare function applyWALPragmas(db: DatabaseInstance): void;
|
|
41
|
+
/**
|
|
42
|
+
* Delete all three SQLite files for a given db path (main, WAL, SHM).
|
|
43
|
+
* Silently ignores individual deletion errors so a partial cleanup
|
|
44
|
+
* does not abort the rest.
|
|
45
|
+
*/
|
|
46
|
+
export declare function deleteDBFiles(dbPath: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Safely close a database connection. Swallows errors so callers can
|
|
49
|
+
* always call this in a finally/cleanup path without try/catch.
|
|
50
|
+
*/
|
|
51
|
+
export declare function closeDB(db: DatabaseInstance): void;
|
|
52
|
+
/**
|
|
53
|
+
* Return the default per-process DB path for context-mode databases.
|
|
54
|
+
* Uses the OS temp directory and embeds the current PID so multiple
|
|
55
|
+
* server instances never share a file.
|
|
56
|
+
*/
|
|
57
|
+
export declare function defaultDBPath(prefix?: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* SQLiteBase — minimal base class that handles open/close/cleanup lifecycle.
|
|
60
|
+
*
|
|
61
|
+
* Subclasses call `super(dbPath)` to open the database with WAL pragmas
|
|
62
|
+
* applied, then implement `initSchema()` and `prepareStatements()`.
|
|
63
|
+
*
|
|
64
|
+
* The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
|
|
65
|
+
*/
|
|
66
|
+
export declare abstract class SQLiteBase {
|
|
67
|
+
#private;
|
|
68
|
+
constructor(dbPath: string);
|
|
69
|
+
/** Called once after WAL pragmas are applied. Subclasses run CREATE TABLE/VIRTUAL TABLE here. */
|
|
70
|
+
protected abstract initSchema(): void;
|
|
71
|
+
/** Called once after schema init. Subclasses compile and cache their prepared statements here. */
|
|
72
|
+
protected abstract prepareStatements(): void;
|
|
73
|
+
/** Raw database instance — available to subclasses only. */
|
|
74
|
+
protected get db(): DatabaseInstance;
|
|
75
|
+
/** The path this database was opened from. */
|
|
76
|
+
get dbPath(): string;
|
|
77
|
+
/** Close the database connection without deleting files. */
|
|
78
|
+
close(): void;
|
|
79
|
+
/**
|
|
80
|
+
* Close the connection and delete all associated DB files (main, WAL, SHM).
|
|
81
|
+
* Call on process exit or at end of session lifecycle.
|
|
82
|
+
*/
|
|
83
|
+
cleanup(): void;
|
|
84
|
+
}
|
package/build/db-base.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-base — Reusable SQLite infrastructure for context-mode packages.
|
|
3
|
+
*
|
|
4
|
+
* Provides lazy-loading of better-sqlite3, WAL pragma setup, prepared
|
|
5
|
+
* statement caching interface, and DB file cleanup helpers. Both
|
|
6
|
+
* ContentStore and SessionDB build on top of these primitives.
|
|
7
|
+
*/
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { unlinkSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
// ─────────────────────────────────────────────────────────
|
|
13
|
+
// Lazy loader
|
|
14
|
+
// ─────────────────────────────────────────────────────────
|
|
15
|
+
let _Database = null;
|
|
16
|
+
/**
|
|
17
|
+
* Lazy-load better-sqlite3. Only resolves the native module on first call.
|
|
18
|
+
* This allows the MCP server to start instantly even when the native addon
|
|
19
|
+
* is not yet installed (marketplace first-run scenario).
|
|
20
|
+
*/
|
|
21
|
+
export function loadDatabase() {
|
|
22
|
+
if (!_Database) {
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
_Database = require("better-sqlite3");
|
|
25
|
+
}
|
|
26
|
+
return _Database;
|
|
27
|
+
}
|
|
28
|
+
// ─────────────────────────────────────────────────────────
|
|
29
|
+
// WAL setup
|
|
30
|
+
// ─────────────────────────────────────────────────────────
|
|
31
|
+
/**
|
|
32
|
+
* Apply WAL mode and NORMAL synchronous pragma to a database instance.
|
|
33
|
+
* Should be called immediately after opening a new database connection.
|
|
34
|
+
*
|
|
35
|
+
* WAL mode provides:
|
|
36
|
+
* - Concurrent readers while a write is in progress
|
|
37
|
+
* - Dramatically faster writes (no full-page sync on each commit)
|
|
38
|
+
* NORMAL synchronous is safe under WAL and avoids an extra fsync per
|
|
39
|
+
* transaction.
|
|
40
|
+
*/
|
|
41
|
+
export function applyWALPragmas(db) {
|
|
42
|
+
db.pragma("journal_mode = WAL");
|
|
43
|
+
db.pragma("synchronous = NORMAL");
|
|
44
|
+
}
|
|
45
|
+
// ─────────────────────────────────────────────────────────
|
|
46
|
+
// DB file helpers
|
|
47
|
+
// ─────────────────────────────────────────────────────────
|
|
48
|
+
/**
|
|
49
|
+
* Delete all three SQLite files for a given db path (main, WAL, SHM).
|
|
50
|
+
* Silently ignores individual deletion errors so a partial cleanup
|
|
51
|
+
* does not abort the rest.
|
|
52
|
+
*/
|
|
53
|
+
export function deleteDBFiles(dbPath) {
|
|
54
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
55
|
+
try {
|
|
56
|
+
unlinkSync(dbPath + suffix);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// ignore — file may not exist
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Safely close a database connection. Swallows errors so callers can
|
|
65
|
+
* always call this in a finally/cleanup path without try/catch.
|
|
66
|
+
*/
|
|
67
|
+
export function closeDB(db) {
|
|
68
|
+
try {
|
|
69
|
+
db.close();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// ignore
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ─────────────────────────────────────────────────────────
|
|
76
|
+
// Default path helper
|
|
77
|
+
// ─────────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Return the default per-process DB path for context-mode databases.
|
|
80
|
+
* Uses the OS temp directory and embeds the current PID so multiple
|
|
81
|
+
* server instances never share a file.
|
|
82
|
+
*/
|
|
83
|
+
export function defaultDBPath(prefix = "context-mode") {
|
|
84
|
+
return join(tmpdir(), `${prefix}-${process.pid}.db`);
|
|
85
|
+
}
|
|
86
|
+
// ─────────────────────────────────────────────────────────
|
|
87
|
+
// Base class
|
|
88
|
+
// ─────────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* SQLiteBase — minimal base class that handles open/close/cleanup lifecycle.
|
|
91
|
+
*
|
|
92
|
+
* Subclasses call `super(dbPath)` to open the database with WAL pragmas
|
|
93
|
+
* applied, then implement `initSchema()` and `prepareStatements()`.
|
|
94
|
+
*
|
|
95
|
+
* The `db` getter exposes the raw `DatabaseInstance` to subclasses only.
|
|
96
|
+
*/
|
|
97
|
+
export class SQLiteBase {
|
|
98
|
+
#dbPath;
|
|
99
|
+
#db;
|
|
100
|
+
constructor(dbPath) {
|
|
101
|
+
const Database = loadDatabase();
|
|
102
|
+
this.#dbPath = dbPath;
|
|
103
|
+
this.#db = new Database(dbPath, { timeout: 5000 });
|
|
104
|
+
applyWALPragmas(this.#db);
|
|
105
|
+
this.initSchema();
|
|
106
|
+
this.prepareStatements();
|
|
107
|
+
}
|
|
108
|
+
/** Raw database instance — available to subclasses only. */
|
|
109
|
+
get db() {
|
|
110
|
+
return this.#db;
|
|
111
|
+
}
|
|
112
|
+
/** The path this database was opened from. */
|
|
113
|
+
get dbPath() {
|
|
114
|
+
return this.#dbPath;
|
|
115
|
+
}
|
|
116
|
+
/** Close the database connection without deleting files. */
|
|
117
|
+
close() {
|
|
118
|
+
closeDB(this.#db);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Close the connection and delete all associated DB files (main, WAL, SHM).
|
|
122
|
+
* Call on process exit or at end of session lifecycle.
|
|
123
|
+
*/
|
|
124
|
+
cleanup() {
|
|
125
|
+
closeDB(this.#db);
|
|
126
|
+
deleteDBFiles(this.#dbPath);
|
|
127
|
+
}
|
|
128
|
+
}
|
package/build/executor.d.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { type RuntimeMap, type Language } from "./runtime.js";
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
stderr: string;
|
|
5
|
-
exitCode: number;
|
|
6
|
-
timedOut: boolean;
|
|
7
|
-
}
|
|
2
|
+
export type { ExecResult } from "./types.js";
|
|
3
|
+
import type { ExecResult } from "./types.js";
|
|
8
4
|
interface ExecuteOptions {
|
|
9
5
|
language: Language;
|
|
10
6
|
code: string;
|
|
11
7
|
timeout?: number;
|
|
8
|
+
/** Keep process running after timeout instead of killing it. */
|
|
9
|
+
background?: boolean;
|
|
12
10
|
}
|
|
13
11
|
interface ExecuteFileOptions extends ExecuteOptions {
|
|
14
12
|
path: string;
|
|
@@ -22,7 +20,8 @@ export declare class PolyglotExecutor {
|
|
|
22
20
|
runtimes?: RuntimeMap;
|
|
23
21
|
});
|
|
24
22
|
get runtimes(): RuntimeMap;
|
|
23
|
+
/** Kill all backgrounded processes to prevent zombie/port-conflict issues. */
|
|
24
|
+
cleanupBackgrounded(): void;
|
|
25
25
|
execute(opts: ExecuteOptions): Promise<ExecResult>;
|
|
26
26
|
executeFile(opts: ExecuteFileOptions): Promise<ExecResult>;
|
|
27
27
|
}
|
|
28
|
-
export {};
|
package/build/executor.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
var _a;
|
|
2
1
|
import { spawn, execSync } from "node:child_process";
|
|
3
2
|
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
|
4
3
|
import { join, resolve } from "node:path";
|
|
5
4
|
import { tmpdir } from "node:os";
|
|
6
5
|
import { detectRuntimes, buildCommand, } from "./runtime.js";
|
|
6
|
+
import { smartTruncate } from "./truncate.js";
|
|
7
7
|
const isWin = process.platform === "win32";
|
|
8
8
|
/** Kill process tree — on Windows, proc.kill() only kills the shell, not children. */
|
|
9
9
|
function killTree(proc) {
|
|
@@ -22,6 +22,8 @@ export class PolyglotExecutor {
|
|
|
22
22
|
#hardCapBytes;
|
|
23
23
|
#projectRoot;
|
|
24
24
|
#runtimes;
|
|
25
|
+
/** PIDs of backgrounded processes — killed on cleanup to prevent zombies. */
|
|
26
|
+
#backgroundedPids = new Set();
|
|
25
27
|
constructor(opts) {
|
|
26
28
|
this.#maxOutputBytes = opts?.maxOutputBytes ?? 102_400;
|
|
27
29
|
this.#hardCapBytes = opts?.hardCapBytes ?? 100 * 1024 * 1024; // 100MB
|
|
@@ -31,8 +33,18 @@ export class PolyglotExecutor {
|
|
|
31
33
|
get runtimes() {
|
|
32
34
|
return { ...this.#runtimes };
|
|
33
35
|
}
|
|
36
|
+
/** Kill all backgrounded processes to prevent zombie/port-conflict issues. */
|
|
37
|
+
cleanupBackgrounded() {
|
|
38
|
+
for (const pid of this.#backgroundedPids) {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, "SIGTERM");
|
|
41
|
+
}
|
|
42
|
+
catch { /* already dead */ }
|
|
43
|
+
}
|
|
44
|
+
this.#backgroundedPids.clear();
|
|
45
|
+
}
|
|
34
46
|
async execute(opts) {
|
|
35
|
-
const { language, code, timeout = 30_000 } = opts;
|
|
47
|
+
const { language, code, timeout = 30_000, background = false } = opts;
|
|
36
48
|
const tmpDir = mkdtempSync(join(tmpdir(), "ctx-mode-"));
|
|
37
49
|
try {
|
|
38
50
|
const filePath = this.#writeScript(tmpDir, code, language);
|
|
@@ -45,16 +57,22 @@ export class PolyglotExecutor {
|
|
|
45
57
|
// and other project-aware tools work naturally. Non-shell languages
|
|
46
58
|
// run in the temp directory where their script file is written.
|
|
47
59
|
const cwd = language === "shell" ? this.#projectRoot : tmpDir;
|
|
48
|
-
|
|
60
|
+
const result = await this.#spawn(cmd, cwd, timeout, background);
|
|
61
|
+
// Skip tmpDir cleanup if process was backgrounded — it may still need files
|
|
62
|
+
if (!result.backgrounded) {
|
|
63
|
+
try {
|
|
64
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
catch { /* ignore */ }
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
49
69
|
}
|
|
50
|
-
|
|
70
|
+
catch (err) {
|
|
51
71
|
try {
|
|
52
72
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
53
73
|
}
|
|
54
|
-
catch {
|
|
55
|
-
|
|
56
|
-
// Ignore EPERM/EBUSY — the OS will clean up %TEMP% eventually.
|
|
57
|
-
}
|
|
74
|
+
catch { /* ignore */ }
|
|
75
|
+
throw err;
|
|
58
76
|
}
|
|
59
77
|
}
|
|
60
78
|
async executeFile(opts) {
|
|
@@ -123,44 +141,7 @@ export class PolyglotExecutor {
|
|
|
123
141
|
// Run
|
|
124
142
|
return this.#spawn([binPath], cwd, timeout);
|
|
125
143
|
}
|
|
126
|
-
|
|
127
|
-
* Smart truncation: keeps head (60%) + tail (40%) of output,
|
|
128
|
-
* preserving both initial context and final error messages.
|
|
129
|
-
* Snaps to line boundaries and handles UTF-8 safely.
|
|
130
|
-
*/
|
|
131
|
-
static #smartTruncate(raw, max) {
|
|
132
|
-
if (Buffer.byteLength(raw) <= max)
|
|
133
|
-
return raw;
|
|
134
|
-
const lines = raw.split("\n");
|
|
135
|
-
// Budget: 60% head, 40% tail (errors/results are usually at the end)
|
|
136
|
-
const headBudget = Math.floor(max * 0.6);
|
|
137
|
-
const tailBudget = max - headBudget;
|
|
138
|
-
// Collect head lines
|
|
139
|
-
const headLines = [];
|
|
140
|
-
let headBytes = 0;
|
|
141
|
-
for (const line of lines) {
|
|
142
|
-
const lineBytes = Buffer.byteLength(line) + 1; // +1 for \n
|
|
143
|
-
if (headBytes + lineBytes > headBudget)
|
|
144
|
-
break;
|
|
145
|
-
headLines.push(line);
|
|
146
|
-
headBytes += lineBytes;
|
|
147
|
-
}
|
|
148
|
-
// Collect tail lines (from end)
|
|
149
|
-
const tailLines = [];
|
|
150
|
-
let tailBytes = 0;
|
|
151
|
-
for (let i = lines.length - 1; i >= headLines.length; i--) {
|
|
152
|
-
const lineBytes = Buffer.byteLength(lines[i]) + 1;
|
|
153
|
-
if (tailBytes + lineBytes > tailBudget)
|
|
154
|
-
break;
|
|
155
|
-
tailLines.unshift(lines[i]);
|
|
156
|
-
tailBytes += lineBytes;
|
|
157
|
-
}
|
|
158
|
-
const skippedLines = lines.length - headLines.length - tailLines.length;
|
|
159
|
-
const skippedBytes = Buffer.byteLength(raw) - headBytes - tailBytes;
|
|
160
|
-
const separator = `\n\n... [${skippedLines} lines / ${(skippedBytes / 1024).toFixed(1)}KB truncated — showing first ${headLines.length} + last ${tailLines.length} lines] ...\n\n`;
|
|
161
|
-
return headLines.join("\n") + separator + tailLines.join("\n");
|
|
162
|
-
}
|
|
163
|
-
async #spawn(cmd, cwd, timeout) {
|
|
144
|
+
async #spawn(cmd, cwd, timeout, background = false) {
|
|
164
145
|
return new Promise((res) => {
|
|
165
146
|
// Only .cmd/.bat shims need shell on Windows; real executables don't.
|
|
166
147
|
// Using shell: true globally causes process-tree kill issues with MSYS2/Git Bash.
|
|
@@ -186,9 +167,31 @@ export class PolyglotExecutor {
|
|
|
186
167
|
shell: needsShell,
|
|
187
168
|
});
|
|
188
169
|
let timedOut = false;
|
|
170
|
+
let resolved = false;
|
|
189
171
|
const timer = setTimeout(() => {
|
|
190
172
|
timedOut = true;
|
|
191
|
-
|
|
173
|
+
if (background) {
|
|
174
|
+
// Background mode: detach process, return partial output, keep running
|
|
175
|
+
resolved = true;
|
|
176
|
+
if (proc.pid)
|
|
177
|
+
this.#backgroundedPids.add(proc.pid);
|
|
178
|
+
proc.unref();
|
|
179
|
+
proc.stdout.destroy();
|
|
180
|
+
proc.stderr.destroy();
|
|
181
|
+
const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
182
|
+
const rawStderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
183
|
+
const max = this.#maxOutputBytes;
|
|
184
|
+
res({
|
|
185
|
+
stdout: smartTruncate(rawStdout, max),
|
|
186
|
+
stderr: smartTruncate(rawStderr, max),
|
|
187
|
+
exitCode: 0,
|
|
188
|
+
timedOut: true,
|
|
189
|
+
backgrounded: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
killTree(proc);
|
|
194
|
+
}
|
|
192
195
|
}, timeout);
|
|
193
196
|
// Stream-level byte cap: kill the process once combined stdout+stderr
|
|
194
197
|
// exceeds hardCapBytes. Without this, a command like `yes` or
|
|
@@ -220,14 +223,16 @@ export class PolyglotExecutor {
|
|
|
220
223
|
});
|
|
221
224
|
proc.on("close", (exitCode) => {
|
|
222
225
|
clearTimeout(timer);
|
|
226
|
+
if (resolved)
|
|
227
|
+
return; // Already resolved by background timeout
|
|
223
228
|
const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
224
229
|
let rawStderr = Buffer.concat(stderrChunks).toString("utf-8");
|
|
225
230
|
if (capExceeded) {
|
|
226
231
|
rawStderr += `\n[output capped at ${(this.#hardCapBytes / 1024 / 1024).toFixed(0)}MB — process killed]`;
|
|
227
232
|
}
|
|
228
233
|
const max = this.#maxOutputBytes;
|
|
229
|
-
const stdout =
|
|
230
|
-
const stderr =
|
|
234
|
+
const stdout = smartTruncate(rawStdout, max);
|
|
235
|
+
const stderr = smartTruncate(rawStderr, max);
|
|
231
236
|
res({
|
|
232
237
|
stdout,
|
|
233
238
|
stderr,
|
|
@@ -237,6 +242,8 @@ export class PolyglotExecutor {
|
|
|
237
242
|
});
|
|
238
243
|
proc.on("error", (err) => {
|
|
239
244
|
clearTimeout(timer);
|
|
245
|
+
if (resolved)
|
|
246
|
+
return; // Already resolved by background timeout
|
|
240
247
|
res({
|
|
241
248
|
stdout: "",
|
|
242
249
|
stderr: err.message,
|
|
@@ -286,6 +293,44 @@ export class PolyglotExecutor {
|
|
|
286
293
|
// Claude Code's PTY ownership.
|
|
287
294
|
"SSH_AUTH_SOCK",
|
|
288
295
|
"SSH_AGENT_PID",
|
|
296
|
+
// Virtual environments (direnv, nix devshells, asdf, mise, etc.)
|
|
297
|
+
"DIRENV_DIR",
|
|
298
|
+
"DIRENV_FILE",
|
|
299
|
+
"DIRENV_DIFF",
|
|
300
|
+
"DIRENV_WATCHES",
|
|
301
|
+
"DIRENV_LAYOUT_DIR",
|
|
302
|
+
"NIX_PATH",
|
|
303
|
+
"NIX_PROFILES",
|
|
304
|
+
"NIX_SSL_CERT_FILE",
|
|
305
|
+
"NIX_CC",
|
|
306
|
+
"NIX_STORE",
|
|
307
|
+
"NIX_BUILD_CORES",
|
|
308
|
+
"IN_NIX_SHELL",
|
|
309
|
+
"LOCALE_ARCHIVE",
|
|
310
|
+
"LD_LIBRARY_PATH",
|
|
311
|
+
"DYLD_LIBRARY_PATH",
|
|
312
|
+
"LIBRARY_PATH",
|
|
313
|
+
"C_INCLUDE_PATH",
|
|
314
|
+
"CPLUS_INCLUDE_PATH",
|
|
315
|
+
"PKG_CONFIG_PATH",
|
|
316
|
+
"CMAKE_PREFIX_PATH",
|
|
317
|
+
"GOPATH",
|
|
318
|
+
"GOROOT",
|
|
319
|
+
"CARGO_HOME",
|
|
320
|
+
"RUSTUP_HOME",
|
|
321
|
+
"ASDF_DIR",
|
|
322
|
+
"ASDF_DATA_DIR",
|
|
323
|
+
"MISE_DATA_DIR",
|
|
324
|
+
"VIRTUAL_ENV",
|
|
325
|
+
"CONDA_PREFIX",
|
|
326
|
+
"CONDA_DEFAULT_ENV",
|
|
327
|
+
"PYTHONPATH",
|
|
328
|
+
"GEM_HOME",
|
|
329
|
+
"GEM_PATH",
|
|
330
|
+
"BUNDLE_PATH",
|
|
331
|
+
"RBENV_ROOT",
|
|
332
|
+
"JAVA_HOME",
|
|
333
|
+
"SDKMAN_DIR",
|
|
289
334
|
];
|
|
290
335
|
const env = {
|
|
291
336
|
PATH: process.env.PATH ?? (isWin ? "" : "/usr/local/bin:/usr/bin:/bin"),
|
|
@@ -302,7 +347,6 @@ export class PolyglotExecutor {
|
|
|
302
347
|
const winVars = [
|
|
303
348
|
"SYSTEMROOT", "SystemRoot", "COMSPEC", "PATHEXT",
|
|
304
349
|
"USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP",
|
|
305
|
-
"GOROOT", "GOPATH",
|
|
306
350
|
];
|
|
307
351
|
for (const key of winVars) {
|
|
308
352
|
if (process.env[key])
|
|
@@ -326,6 +370,23 @@ export class PolyglotExecutor {
|
|
|
326
370
|
env[key] = process.env[key];
|
|
327
371
|
}
|
|
328
372
|
}
|
|
373
|
+
// Ensure SSL_CERT_FILE is set so Python/Ruby HTTPS works in sandbox.
|
|
374
|
+
// On macOS, it's typically unset (Python uses its own bundle or none),
|
|
375
|
+
// causing urllib/requests to fail with SSL cert verification errors.
|
|
376
|
+
if (!env["SSL_CERT_FILE"]) {
|
|
377
|
+
const certPaths = isWin ? [] : [
|
|
378
|
+
"/etc/ssl/cert.pem", // macOS, some Linux
|
|
379
|
+
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Alpine
|
|
380
|
+
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
|
|
381
|
+
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // Fedora alt
|
|
382
|
+
];
|
|
383
|
+
for (const p of certPaths) {
|
|
384
|
+
if (existsSync(p)) {
|
|
385
|
+
env["SSL_CERT_FILE"] = p;
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
329
390
|
return env;
|
|
330
391
|
}
|
|
331
392
|
#wrapWithFileContent(absolutePath, language, code) {
|
|
@@ -358,4 +419,3 @@ export class PolyglotExecutor {
|
|
|
358
419
|
}
|
|
359
420
|
}
|
|
360
421
|
}
|
|
361
|
-
_a = PolyglotExecutor;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode TypeScript plugin entry point for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* Provides three hooks:
|
|
5
|
+
* - tool.execute.before — Routing enforcement (deny/modify/passthrough)
|
|
6
|
+
* - tool.execute.after — Session event capture
|
|
7
|
+
* - experimental.session.compacting — Compaction snapshot generation
|
|
8
|
+
*
|
|
9
|
+
* Loaded by OpenCode via: import("context-mode/plugin").ContextModePlugin(ctx)
|
|
10
|
+
*
|
|
11
|
+
* Constraints:
|
|
12
|
+
* - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
|
|
13
|
+
* - No context injection (canInjectSessionContext: false)
|
|
14
|
+
* - Session cleanup happens at plugin init (no SessionStart)
|
|
15
|
+
*/
|
|
16
|
+
/** OpenCode plugin context passed to the factory function. */
|
|
17
|
+
interface PluginContext {
|
|
18
|
+
directory: string;
|
|
19
|
+
}
|
|
20
|
+
/** Shape of the input object OpenCode passes to hook functions. */
|
|
21
|
+
interface ToolHookInput {
|
|
22
|
+
tool_name?: string;
|
|
23
|
+
tool_input?: Record<string, unknown>;
|
|
24
|
+
tool_output?: string;
|
|
25
|
+
is_error?: boolean;
|
|
26
|
+
sessionID?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* OpenCode plugin factory. Called once when OpenCode loads the plugin.
|
|
30
|
+
* Returns an object mapping hook event names to async handler functions.
|
|
31
|
+
*/
|
|
32
|
+
export declare const ContextModePlugin: (ctx: PluginContext) => Promise<{
|
|
33
|
+
"tool.execute.before": (input: ToolHookInput) => Promise<void>;
|
|
34
|
+
"tool.execute.after": (input: ToolHookInput) => Promise<void>;
|
|
35
|
+
"experimental.session.compacting": () => Promise<string>;
|
|
36
|
+
}>;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode TypeScript plugin entry point for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* Provides three hooks:
|
|
5
|
+
* - tool.execute.before — Routing enforcement (deny/modify/passthrough)
|
|
6
|
+
* - tool.execute.after — Session event capture
|
|
7
|
+
* - experimental.session.compacting — Compaction snapshot generation
|
|
8
|
+
*
|
|
9
|
+
* Loaded by OpenCode via: import("context-mode/plugin").ContextModePlugin(ctx)
|
|
10
|
+
*
|
|
11
|
+
* Constraints:
|
|
12
|
+
* - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
|
|
13
|
+
* - No context injection (canInjectSessionContext: false)
|
|
14
|
+
* - Session cleanup happens at plugin init (no SessionStart)
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
17
|
+
import { mkdirSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
import { SessionDB } from "./session/db.js";
|
|
22
|
+
import { extractEvents } from "./session/extract.js";
|
|
23
|
+
import { buildResumeSnapshot } from "./session/snapshot.js";
|
|
24
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
25
|
+
function getSessionDir() {
|
|
26
|
+
const dir = join(homedir(), ".config", "opencode", "context-mode", "sessions");
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
function getDBPath(projectDir) {
|
|
31
|
+
const hash = createHash("sha256")
|
|
32
|
+
.update(projectDir)
|
|
33
|
+
.digest("hex")
|
|
34
|
+
.slice(0, 16);
|
|
35
|
+
return join(getSessionDir(), `${hash}.db`);
|
|
36
|
+
}
|
|
37
|
+
// ── Plugin Factory ────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* OpenCode plugin factory. Called once when OpenCode loads the plugin.
|
|
40
|
+
* Returns an object mapping hook event names to async handler functions.
|
|
41
|
+
*/
|
|
42
|
+
export const ContextModePlugin = async (ctx) => {
|
|
43
|
+
// Resolve build dir from compiled JS location
|
|
44
|
+
const buildDir = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
// Load routing module (ESM .mjs, lives outside build/ in hooks/)
|
|
46
|
+
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
|
|
47
|
+
const routing = await import(pathToFileURL(routingPath).href);
|
|
48
|
+
await routing.initSecurity(buildDir);
|
|
49
|
+
// Initialize session
|
|
50
|
+
const projectDir = ctx.directory;
|
|
51
|
+
const db = new SessionDB({ dbPath: getDBPath(projectDir) });
|
|
52
|
+
const sessionId = randomUUID();
|
|
53
|
+
db.ensureSession(sessionId, projectDir);
|
|
54
|
+
// Clean up old sessions on startup (replaces SessionStart hook)
|
|
55
|
+
db.cleanupOldSessions(0);
|
|
56
|
+
return {
|
|
57
|
+
// ── PreToolUse: Routing enforcement ─────────────────
|
|
58
|
+
"tool.execute.before": async (input) => {
|
|
59
|
+
const toolName = input.tool_name ?? "";
|
|
60
|
+
const toolInput = input.tool_input ?? {};
|
|
61
|
+
let decision;
|
|
62
|
+
try {
|
|
63
|
+
decision = routing.routePreToolUse(toolName, toolInput, projectDir);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return; // Routing failure → allow passthrough
|
|
67
|
+
}
|
|
68
|
+
if (!decision)
|
|
69
|
+
return; // No routing match → passthrough
|
|
70
|
+
if (decision.action === "deny" || decision.action === "ask") {
|
|
71
|
+
// Throw to block — OpenCode catches this and denies the tool call
|
|
72
|
+
throw new Error(decision.reason ?? "Blocked by context-mode");
|
|
73
|
+
}
|
|
74
|
+
if (decision.action === "modify" && decision.updatedInput) {
|
|
75
|
+
// Mutate args in place — OpenCode reads the mutated input
|
|
76
|
+
Object.assign(toolInput, decision.updatedInput);
|
|
77
|
+
}
|
|
78
|
+
// "context" action → no-op (OpenCode doesn't support context injection)
|
|
79
|
+
},
|
|
80
|
+
// ── PostToolUse: Session event capture ──────────────
|
|
81
|
+
"tool.execute.after": async (input) => {
|
|
82
|
+
try {
|
|
83
|
+
const hookInput = {
|
|
84
|
+
tool_name: input.tool_name ?? "",
|
|
85
|
+
tool_input: input.tool_input ?? {},
|
|
86
|
+
tool_response: input.tool_output,
|
|
87
|
+
tool_output: input.is_error ? { isError: true } : undefined,
|
|
88
|
+
};
|
|
89
|
+
const events = extractEvents(hookInput);
|
|
90
|
+
for (const event of events) {
|
|
91
|
+
// Cast: extract.ts SessionEvent lacks data_hash (computed by insertEvent)
|
|
92
|
+
db.insertEvent(sessionId, event, "PostToolUse");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Silent — session capture must never break the tool call
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
// ── PreCompact: Snapshot generation ─────────────────
|
|
100
|
+
"experimental.session.compacting": async () => {
|
|
101
|
+
try {
|
|
102
|
+
const events = db.getEvents(sessionId);
|
|
103
|
+
if (events.length === 0)
|
|
104
|
+
return "";
|
|
105
|
+
const stats = db.getSessionStats(sessionId);
|
|
106
|
+
const snapshot = buildResumeSnapshot(events, {
|
|
107
|
+
compactCount: (stats?.compact_count ?? 0) + 1,
|
|
108
|
+
});
|
|
109
|
+
db.upsertResume(sessionId, snapshot, events.length);
|
|
110
|
+
db.incrementCompactCount(sessionId);
|
|
111
|
+
return snapshot;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
};
|