gsd-pi 2.76.0-dev.82e249f7b → 2.76.0-dev.fe143342a

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 (139) hide show
  1. package/dist/mcp-server.d.ts +7 -0
  2. package/dist/mcp-server.js +35 -1
  3. package/dist/resource-loader.d.ts +1 -1
  4. package/dist/resource-loader.js +2 -8
  5. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +66 -4
  6. package/dist/resources/extensions/gsd/auto-start.js +27 -14
  7. package/dist/resources/extensions/gsd/auto.js +11 -11
  8. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +39 -9
  9. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +93 -0
  10. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  11. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +35 -0
  12. package/dist/resources/extensions/gsd/compaction-snapshot.js +121 -0
  13. package/dist/resources/extensions/gsd/error-classifier.js +10 -3
  14. package/dist/resources/extensions/gsd/exec-history.js +120 -0
  15. package/dist/resources/extensions/gsd/exec-sandbox.js +258 -0
  16. package/dist/resources/extensions/gsd/gsd-db.js +62 -4
  17. package/dist/resources/extensions/gsd/init-wizard.js +15 -1
  18. package/dist/resources/extensions/gsd/key-manager.js +6 -0
  19. package/dist/resources/extensions/gsd/pre-execution-checks.js +13 -3
  20. package/dist/resources/extensions/gsd/preferences-types.js +9 -0
  21. package/dist/resources/extensions/gsd/preferences-validation.js +83 -0
  22. package/dist/resources/extensions/gsd/preferences.js +17 -17
  23. package/dist/resources/extensions/gsd/safety/file-change-validator.js +1 -1
  24. package/dist/resources/extensions/gsd/tools/exec-search-tool.js +59 -0
  25. package/dist/resources/extensions/gsd/tools/exec-tool.js +126 -0
  26. package/dist/resources/extensions/gsd/tools/resume-tool.js +23 -0
  27. package/dist/resources/extensions/gsd/workflow-mcp.js +3 -0
  28. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  29. package/dist/web/standalone/.next/BUILD_ID +1 -1
  30. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  31. package/dist/web/standalone/.next/build-manifest.json +2 -2
  32. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  33. package/dist/web/standalone/.next/required-server-files.json +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.html +1 -1
  51. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  52. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  53. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  54. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  55. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  58. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  59. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  60. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  61. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  62. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  63. package/dist/web/standalone/server.js +1 -1
  64. package/package.json +1 -1
  65. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  66. package/packages/mcp-server/dist/workflow-tools.js +64 -25
  67. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  68. package/packages/mcp-server/src/workflow-tools.test.ts +146 -1
  69. package/packages/mcp-server/src/workflow-tools.ts +84 -43
  70. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  71. package/packages/pi-coding-agent/dist/core/redact-secrets.d.ts +2 -0
  72. package/packages/pi-coding-agent/dist/core/redact-secrets.d.ts.map +1 -0
  73. package/packages/pi-coding-agent/dist/core/redact-secrets.js +49 -0
  74. package/packages/pi-coding-agent/dist/core/redact-secrets.js.map +1 -0
  75. package/packages/pi-coding-agent/dist/core/redact-secrets.test.d.ts +2 -0
  76. package/packages/pi-coding-agent/dist/core/redact-secrets.test.d.ts.map +1 -0
  77. package/packages/pi-coding-agent/dist/core/redact-secrets.test.js +67 -0
  78. package/packages/pi-coding-agent/dist/core/redact-secrets.test.js.map +1 -0
  79. package/packages/pi-coding-agent/dist/core/session-manager.d.ts.map +1 -1
  80. package/packages/pi-coding-agent/dist/core/session-manager.js +9 -5
  81. package/packages/pi-coding-agent/dist/core/session-manager.js.map +1 -1
  82. package/packages/pi-coding-agent/dist/core/session-manager.test.js +25 -1
  83. package/packages/pi-coding-agent/dist/core/session-manager.test.js.map +1 -1
  84. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.d.ts +1 -1
  85. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.js +5 -4
  87. package/packages/pi-coding-agent/dist/modes/interactive/components/chat-frame.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/skill-invocation-message.d.ts +7 -6
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/skill-invocation-message.d.ts.map +1 -1
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/skill-invocation-message.js +29 -21
  91. package/packages/pi-coding-agent/dist/modes/interactive/components/skill-invocation-message.js.map +1 -1
  92. package/packages/pi-coding-agent/src/core/redact-secrets.test.ts +86 -0
  93. package/packages/pi-coding-agent/src/core/redact-secrets.ts +58 -0
  94. package/packages/pi-coding-agent/src/core/session-manager.test.ts +36 -1
  95. package/packages/pi-coding-agent/src/core/session-manager.ts +9 -5
  96. package/packages/pi-coding-agent/src/modes/interactive/components/chat-frame.ts +6 -6
  97. package/packages/pi-coding-agent/src/modes/interactive/components/skill-invocation-message.ts +36 -22
  98. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  99. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +67 -4
  100. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +137 -2
  101. package/src/resources/extensions/gsd/auto-start.ts +28 -15
  102. package/src/resources/extensions/gsd/auto.ts +11 -11
  103. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +40 -9
  104. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +109 -0
  105. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  106. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +36 -0
  107. package/src/resources/extensions/gsd/compaction-snapshot.ts +165 -0
  108. package/src/resources/extensions/gsd/error-classifier.ts +10 -3
  109. package/src/resources/extensions/gsd/exec-history.ts +153 -0
  110. package/src/resources/extensions/gsd/exec-sandbox.ts +326 -0
  111. package/src/resources/extensions/gsd/gsd-db.ts +68 -4
  112. package/src/resources/extensions/gsd/init-wizard.ts +15 -1
  113. package/src/resources/extensions/gsd/key-manager.ts +6 -0
  114. package/src/resources/extensions/gsd/pre-execution-checks.ts +13 -3
  115. package/src/resources/extensions/gsd/preferences-types.ts +38 -0
  116. package/src/resources/extensions/gsd/preferences-validation.ts +79 -0
  117. package/src/resources/extensions/gsd/preferences.ts +17 -17
  118. package/src/resources/extensions/gsd/safety/file-change-validator.ts +1 -1
  119. package/src/resources/extensions/gsd/tests/compaction-snapshot.test.ts +123 -0
  120. package/src/resources/extensions/gsd/tests/exec-history.test.ts +124 -0
  121. package/src/resources/extensions/gsd/tests/exec-sandbox.test.ts +210 -0
  122. package/src/resources/extensions/gsd/tests/file-change-validator.test.ts +20 -0
  123. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +151 -0
  124. package/src/resources/extensions/gsd/tests/init-wizard.test.ts +27 -0
  125. package/src/resources/extensions/gsd/tests/isolation-none-branch-guard.test.ts +1 -1
  126. package/src/resources/extensions/gsd/tests/key-manager.test.ts +7 -0
  127. package/src/resources/extensions/gsd/tests/pre-exec-backtick-strip.test.ts +14 -0
  128. package/src/resources/extensions/gsd/tests/pre-execution-checks.test.ts +19 -0
  129. package/src/resources/extensions/gsd/tests/preferences.test.ts +110 -0
  130. package/src/resources/extensions/gsd/tests/provider-errors.test.ts +48 -0
  131. package/src/resources/extensions/gsd/tests/save-gate-result-render.test.ts +95 -0
  132. package/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +3 -1
  133. package/src/resources/extensions/gsd/tools/exec-search-tool.ts +81 -0
  134. package/src/resources/extensions/gsd/tools/exec-tool.ts +183 -0
  135. package/src/resources/extensions/gsd/tools/resume-tool.ts +40 -0
  136. package/src/resources/extensions/gsd/workflow-logger.ts +2 -1
  137. package/src/resources/extensions/gsd/workflow-mcp.ts +3 -0
  138. /package/dist/web/standalone/.next/static/{ecSsu49rxxcpbNmVP4mLD → n21VtX2hZlkpdEUO_nU4z}/_buildManifest.js +0 -0
  139. /package/dist/web/standalone/.next/static/{ecSsu49rxxcpbNmVP4mLD → n21VtX2hZlkpdEUO_nU4z}/_ssgManifest.js +0 -0
@@ -204,6 +204,41 @@ export function registerHooks(pi, ecosystemHandlers) {
204
204
  nextAction: `Resume task ${state.activeTask.id}: ${state.activeTask.title}.`,
205
205
  }));
206
206
  });
207
+ // Context-mode snapshot: write .gsd/last-snapshot.md before compaction so
208
+ // agents can call gsd_resume (or Read the file) to re-orient. Opt-in via
209
+ // preferences.context_mode.enabled. Runs after the auto-cancel handler
210
+ // above — if that one returned cancel:true, pi still fires us but the
211
+ // compaction won't actually happen; the snapshot is still useful then,
212
+ // since auto may pause and resume later.
213
+ pi.on("session_before_compact", async () => {
214
+ try {
215
+ const { loadEffectiveGSDPreferences } = await import("../preferences.js");
216
+ const { isContextModeEnabled } = await import("../preferences-types.js");
217
+ const prefs = loadEffectiveGSDPreferences();
218
+ if (!isContextModeEnabled(prefs?.preferences))
219
+ return;
220
+ const { writeCompactionSnapshot } = await import("../compaction-snapshot.js");
221
+ const { ensureDbOpen } = await import("./dynamic-tools.js");
222
+ await ensureDbOpen();
223
+ const basePath = process.cwd();
224
+ let activeContext = null;
225
+ try {
226
+ const state = await deriveState(basePath);
227
+ if (state.activeMilestone && state.activeSlice && state.activeTask) {
228
+ activeContext =
229
+ `Active: ${state.activeMilestone.id} / ${state.activeSlice.id} / ${state.activeTask.id}` +
230
+ (state.activeTask.title ? ` — ${state.activeTask.title}` : "");
231
+ }
232
+ }
233
+ catch {
234
+ /* non-fatal */
235
+ }
236
+ writeCompactionSnapshot(basePath, { activeContext });
237
+ }
238
+ catch (err) {
239
+ safetyLogWarning("context-mode", `failed to write compaction snapshot: ${err instanceof Error ? err.message : String(err)}`);
240
+ }
241
+ });
207
242
  pi.on("session_shutdown", async (_event, ctx) => {
208
243
  if (isParallelActive()) {
209
244
  try {
@@ -0,0 +1,121 @@
1
+ // GSD Compaction Snapshot — writes a ≤2 KB markdown digest of durable
2
+ // project state before the session context is compacted. On resume, an
3
+ // agent can `gsd_resume` (or Read .gsd/last-snapshot.md) to re-orient
4
+ // without re-deriving the same memories.
5
+ //
6
+ // Inspired by mksglu/context-mode. Independent implementation.
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { getActiveMemoriesRanked } from "./memory-store.js";
10
+ import { listExecHistory } from "./exec-history.js";
11
+ export const DEFAULT_SNAPSHOT_BYTES = 2048;
12
+ export const SNAPSHOT_FILENAME = "last-snapshot.md";
13
+ /**
14
+ * Build a priority-tiered markdown snapshot. Pure — no I/O. Tiers:
15
+ * 1. Active context (if any)
16
+ * 2. Top memories by rank
17
+ * 3. Recent exec runs (failures highlighted)
18
+ * The result is guaranteed to be <= opts.maxBytes (truncated with an
19
+ * ellipsis marker if necessary).
20
+ */
21
+ export function buildSnapshot(sources, opts = {}) {
22
+ const maxBytes = opts.maxBytes ?? DEFAULT_SNAPSHOT_BYTES;
23
+ const maxMemories = opts.maxMemories ?? 6;
24
+ const maxExec = opts.maxExec ?? 5;
25
+ const lines = [];
26
+ lines.push(`# GSD context snapshot (${sources.generatedAt.toISOString()})`);
27
+ lines.push("");
28
+ if (sources.activeContext && sources.activeContext.trim().length > 0) {
29
+ lines.push("## Active context");
30
+ lines.push(sources.activeContext.trim());
31
+ lines.push("");
32
+ }
33
+ const memories = sources.memories.slice(0, maxMemories);
34
+ if (memories.length > 0) {
35
+ lines.push("## Top project memories");
36
+ for (const memory of memories) {
37
+ lines.push(`- [${memory.id}] (${memory.category}) ${memory.content.trim()}`);
38
+ }
39
+ lines.push("");
40
+ }
41
+ const exec = sources.execHistory.slice(0, maxExec);
42
+ if (exec.length > 0) {
43
+ lines.push("## Recent gsd_exec runs");
44
+ for (const entry of exec) {
45
+ const status = entry.timed_out
46
+ ? "timeout"
47
+ : entry.exit_code === null
48
+ ? "exit:null"
49
+ : `exit:${entry.exit_code}`;
50
+ const purpose = entry.purpose ? ` — ${entry.purpose}` : "";
51
+ lines.push(`- [${entry.id}] ${entry.runtime} ${status}${purpose}`);
52
+ }
53
+ lines.push("");
54
+ }
55
+ if (memories.length === 0 && exec.length === 0 && !sources.activeContext) {
56
+ lines.push("_No durable memories, active context, or exec history to surface._");
57
+ }
58
+ return enforceByteCap(lines.join("\n").trimEnd(), maxBytes);
59
+ }
60
+ function enforceByteCap(input, maxBytes) {
61
+ if (Buffer.byteLength(input, "utf-8") <= maxBytes)
62
+ return input;
63
+ const marker = "\n…[truncated]";
64
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
65
+ const budget = Math.max(0, maxBytes - markerBytes);
66
+ // Walk backwards until the trimmed string fits. utf-8 is variable-width;
67
+ // naive char slicing is safe for ASCII but may split a multi-byte char.
68
+ // Guard by decoding the trimmed Buffer and relying on the replacement-char
69
+ // fallback in TextDecoder (implicit via toString).
70
+ const buf = Buffer.from(input, "utf-8").subarray(0, budget);
71
+ return `${buf.toString("utf-8")}${marker}`;
72
+ }
73
+ export function writeCompactionSnapshot(baseDir, opts = {}) {
74
+ const memories = safeGetMemories();
75
+ const execHistory = safeListExec(baseDir);
76
+ const content = buildSnapshot({
77
+ memories,
78
+ execHistory,
79
+ generatedAt: (opts.now ?? (() => new Date()))(),
80
+ activeContext: opts.activeContext ?? null,
81
+ }, opts);
82
+ const gsdDir = resolve(baseDir, ".gsd");
83
+ if (!existsSync(gsdDir))
84
+ mkdirSync(gsdDir, { recursive: true });
85
+ const path = resolve(gsdDir, SNAPSHOT_FILENAME);
86
+ const finalContent = `${content}\n`;
87
+ writeFileSync(path, finalContent, "utf-8");
88
+ return {
89
+ path,
90
+ bytes: Buffer.byteLength(finalContent, "utf-8"),
91
+ memories: memories.length,
92
+ execRuns: execHistory.length,
93
+ };
94
+ }
95
+ export function readCompactionSnapshot(baseDir) {
96
+ const path = resolve(baseDir, ".gsd", SNAPSHOT_FILENAME);
97
+ if (!existsSync(path))
98
+ return null;
99
+ try {
100
+ return readFileSync(path, "utf-8");
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ function safeGetMemories() {
107
+ try {
108
+ return getActiveMemoriesRanked(12);
109
+ }
110
+ catch {
111
+ return [];
112
+ }
113
+ }
114
+ function safeListExec(baseDir) {
115
+ try {
116
+ return listExecHistory(baseDir);
117
+ }
118
+ catch {
119
+ return [];
120
+ }
121
+ }
@@ -22,12 +22,19 @@ const PERMANENT_RE = /auth|unauthorized|forbidden|invalid.*key|invalid.*api|bill
22
22
  // Include provider-specific quota-window phrasing like:
23
23
  // - "You've hit your limit"
24
24
  // - "usage limit" / "quota reached"
25
- const RATE_LIMIT_RE = /rate.?limit|too many requests|429|hit your limit|usage limit|quota (?:reached|hit)|limit.*resets?/i;
25
+ // - "out of extra usage"
26
+ const RATE_LIMIT_RE = /rate.?limit|too many requests|429|hit your limit|usage limit|out of extra usage|quota (?:reached|hit)|limit.*resets?/i;
26
27
  // OpenRouter affordability-style quota errors should be treated as transient
27
28
  // so core retry logic can lower maxTokens and continue in-session.
28
29
  const AFFORDABILITY_RE = /requires more credits|can only afford|insufficient credits|not enough credits|fewer max_tokens/i;
29
- const NETWORK_RE = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns|unexpected eof/i;
30
- const SERVER_RE = /internal server error|500|502|503|overloaded|server_error|api_error|service.?unavailable/i;
30
+ // "Stream idle timeout" and "partial response received" are emitted by the SDK/harness
31
+ // for mid-stream disconnects. Both indicate a transient network-level interruption.
32
+ // See: https://github.com/gsd-build/gsd-2/issues/4558
33
+ const NETWORK_RE = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns|unexpected eof|stream idle timeout|partial response received/i;
34
+ // Context overflow errors (context window/length exceeded) should be treated as server-class
35
+ // transient errors so auto-mode can retry with reduced budget or fall back to a larger-context model.
36
+ // See: https://github.com/gsd-build/gsd-2/issues/4528
37
+ const SERVER_RE = /internal server error|500|502|503|overloaded|server_error|api_error|service.?unavailable|context (?:window|length) exceed|context window exceed/i;
31
38
  // ECONNRESET/ECONNREFUSED are in NETWORK_RE (same-model retry first).
32
39
  const CONNECTION_RE = /terminated|connection.?(?:refused|error)|other side closed|EPIPE|network.?(?:is\s+)?unavailable|stream_exhausted(?:_without_result)?/i;
33
40
  // Catch-all for V8 JSON.parse errors: all modern variants end with "in JSON at position \d+".
@@ -0,0 +1,120 @@
1
+ // GSD Exec History — read-side helpers for the exec sandbox.
2
+ //
3
+ // Pure I/O: scans `.gsd/exec/*.meta.json` under a base directory and
4
+ // returns lightweight records. Used by the gsd_exec_search tool and
5
+ // any future compaction-snapshot enrichment.
6
+ import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ function listMetaFiles(baseDir) {
9
+ const dir = resolve(baseDir, ".gsd", "exec");
10
+ try {
11
+ return readdirSync(dir)
12
+ .filter((name) => name.endsWith(".meta.json"))
13
+ .map((name) => join(dir, name));
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
19
+ function safeReadMeta(path) {
20
+ try {
21
+ const raw = readFileSync(path, "utf-8");
22
+ const parsed = JSON.parse(raw);
23
+ if (typeof parsed.id !== "string" || typeof parsed.runtime !== "string")
24
+ return null;
25
+ return {
26
+ id: parsed.id,
27
+ runtime: parsed.runtime,
28
+ purpose: typeof parsed.purpose === "string" ? parsed.purpose : null,
29
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : "",
30
+ finished_at: typeof parsed.finished_at === "string" ? parsed.finished_at : "",
31
+ duration_ms: typeof parsed.duration_ms === "number" ? parsed.duration_ms : 0,
32
+ exit_code: typeof parsed.exit_code === "number" ? parsed.exit_code : null,
33
+ signal: typeof parsed.signal === "string" ? parsed.signal : null,
34
+ timed_out: parsed.timed_out === true,
35
+ stdout_bytes: typeof parsed.stdout_bytes === "number" ? parsed.stdout_bytes : 0,
36
+ stderr_bytes: typeof parsed.stderr_bytes === "number" ? parsed.stderr_bytes : 0,
37
+ stdout_truncated: parsed.stdout_truncated === true,
38
+ stderr_truncated: parsed.stderr_truncated === true,
39
+ stdout_path: path.replace(/\.meta\.json$/, ".stdout"),
40
+ stderr_path: path.replace(/\.meta\.json$/, ".stderr"),
41
+ meta_path: path,
42
+ };
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ export function listExecHistory(baseDir) {
49
+ const metas = listMetaFiles(baseDir)
50
+ .map((path) => {
51
+ let mtime = 0;
52
+ try {
53
+ mtime = statSync(path).mtimeMs;
54
+ }
55
+ catch {
56
+ /* ignore */
57
+ }
58
+ const entry = safeReadMeta(path);
59
+ return entry ? { entry, mtime } : null;
60
+ })
61
+ .filter((value) => value !== null);
62
+ metas.sort((a, b) => b.mtime - a.mtime);
63
+ return metas.map((m) => m.entry);
64
+ }
65
+ function matchesFilters(entry, opts) {
66
+ if (opts.runtime && entry.runtime !== opts.runtime)
67
+ return false;
68
+ if (opts.failing_only) {
69
+ const failed = entry.timed_out || (entry.exit_code !== 0 && entry.exit_code !== null);
70
+ if (!failed)
71
+ return false;
72
+ }
73
+ const query = (opts.query ?? "").trim().toLowerCase();
74
+ if (!query)
75
+ return true;
76
+ const haystack = `${entry.id} ${entry.purpose ?? ""}`.toLowerCase();
77
+ return haystack.includes(query);
78
+ }
79
+ function readDigestPreview(entry, maxChars) {
80
+ if (!entry.stdout_path || maxChars <= 0)
81
+ return undefined;
82
+ try {
83
+ const size = statSync(entry.stdout_path).size;
84
+ if (size === 0)
85
+ return undefined;
86
+ const readBytes = Math.min(size, maxChars * 4); // 4 bytes/char upper bound for UTF-8
87
+ const buf = Buffer.allocUnsafe(readBytes);
88
+ const fd = openSync(entry.stdout_path, "r");
89
+ try {
90
+ const bytesRead = readSync(fd, buf, 0, readBytes, Math.max(0, size - readBytes));
91
+ const text = buf.subarray(0, bytesRead).toString("utf-8");
92
+ const trimmed = text.trimEnd();
93
+ return trimmed.length <= maxChars ? trimmed : trimmed.slice(trimmed.length - maxChars);
94
+ }
95
+ finally {
96
+ closeSync(fd);
97
+ }
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ export function searchExecHistory(baseDir, opts = {}) {
104
+ const limit = clampLimit(opts.limit, 20, 200);
105
+ const entries = listExecHistory(baseDir);
106
+ const filtered = entries.filter((entry) => matchesFilters(entry, opts));
107
+ return filtered.slice(0, limit).map((entry) => ({
108
+ entry,
109
+ digest_preview: readDigestPreview(entry, 300),
110
+ }));
111
+ }
112
+ function clampLimit(value, fallback, max) {
113
+ if (typeof value !== "number" || !Number.isFinite(value))
114
+ return fallback;
115
+ if (value < 1)
116
+ return 1;
117
+ if (value > max)
118
+ return max;
119
+ return Math.floor(value);
120
+ }
@@ -0,0 +1,258 @@
1
+ // GSD Exec Sandbox — tool-output sandboxing for sub-sessions.
2
+ //
3
+ // Runs a script in a subprocess and persists stdout/stderr to
4
+ // `.gsd/exec/<id>.{stdout,stderr,meta.json}`. Only a short digest is
5
+ // returned to the calling agent's context, keeping large outputs
6
+ // (e.g. Playwright snapshots, issue dumps) out of the window.
7
+ //
8
+ // Inspired by mksglu/context-mode (Elastic License 2.0). Independent
9
+ // implementation — no upstream code incorporated.
10
+ import { spawn } from "node:child_process";
11
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { randomUUID } from "node:crypto";
13
+ import { resolve } from "node:path";
14
+ const ALWAYS_FORWARD_ENV = ["PATH", "HOME"];
15
+ export const EXEC_DEFAULTS = {
16
+ clampTimeoutMs: 600_000,
17
+ defaultTimeoutMs: 30_000,
18
+ stdoutCapBytes: 1_048_576,
19
+ stderrCapBytes: 262_144,
20
+ digestChars: 300,
21
+ envAllowlist: [
22
+ "LANG",
23
+ "LC_ALL",
24
+ "TERM",
25
+ "TZ",
26
+ "SHELL",
27
+ "USER",
28
+ "LOGNAME",
29
+ "TMPDIR",
30
+ "NODE_OPTIONS",
31
+ "PYTHONPATH",
32
+ "PYTHONIOENCODING",
33
+ ],
34
+ };
35
+ function buildChildEnv(opts) {
36
+ const source = opts.env ?? process.env;
37
+ const out = {};
38
+ const allowed = new Set([...ALWAYS_FORWARD_ENV, ...opts.env_allowlist]);
39
+ for (const key of allowed) {
40
+ const value = source[key];
41
+ if (typeof value === "string")
42
+ out[key] = value;
43
+ }
44
+ return out;
45
+ }
46
+ function clampTimeout(request, opts) {
47
+ const requested = typeof request.timeout_ms === "number" && Number.isFinite(request.timeout_ms)
48
+ ? Math.floor(request.timeout_ms)
49
+ : opts.default_timeout_ms;
50
+ if (requested < 1)
51
+ return 1;
52
+ if (requested > opts.clamp_timeout_ms)
53
+ return opts.clamp_timeout_ms;
54
+ return requested;
55
+ }
56
+ function resolveCommand(runtime) {
57
+ switch (runtime) {
58
+ case "bash":
59
+ return { cmd: "bash", args: ["-c"] };
60
+ case "node":
61
+ return { cmd: process.execPath, args: ["-e"] };
62
+ case "python":
63
+ return { cmd: "python3", args: ["-c"] };
64
+ }
65
+ }
66
+ function tail(buf, chars) {
67
+ if (chars <= 0)
68
+ return "";
69
+ const text = buf.toString("utf-8");
70
+ return text.length <= chars ? text : text.slice(text.length - chars);
71
+ }
72
+ /**
73
+ * Run a script in a subprocess, capture stdout/stderr to files under
74
+ * `.gsd/exec/<id>.{stdout,stderr,meta.json}`, and return an `ExecSandboxResult`
75
+ * containing the digest plus metadata.
76
+ *
77
+ * Errors from spawn failures resolve (not reject) with `exit_code=null`.
78
+ * The function is pure with respect to its inputs — no global state beyond
79
+ * filesystem writes under `baseDir`.
80
+ */
81
+ export function runExecSandbox(request, opts) {
82
+ return new Promise((resolveP) => {
83
+ const id = (opts.generateId ?? defaultGenerateId)();
84
+ const now = (opts.now ?? (() => new Date()))();
85
+ const execDir = resolve(opts.baseDir, ".gsd", "exec");
86
+ if (!existsSync(execDir))
87
+ mkdirSync(execDir, { recursive: true });
88
+ const stdoutPath = resolve(execDir, `${id}.stdout`);
89
+ const stderrPath = resolve(execDir, `${id}.stderr`);
90
+ const metaPath = resolve(execDir, `${id}.meta.json`);
91
+ const timeoutMs = clampTimeout(request, opts);
92
+ const { cmd, args } = resolveCommand(request.runtime);
93
+ const env = buildChildEnv(opts);
94
+ const useProcessGroup = process.platform !== "win32";
95
+ const started = Date.now();
96
+ let child;
97
+ try {
98
+ child = spawn(cmd, [...args, request.script], {
99
+ cwd: opts.baseDir,
100
+ env,
101
+ stdio: ["ignore", "pipe", "pipe"],
102
+ ...(useProcessGroup ? { detached: true } : {}),
103
+ });
104
+ }
105
+ catch (err) {
106
+ const duration = Date.now() - started;
107
+ const message = err instanceof Error ? err.message : String(err);
108
+ writeFileSync(stdoutPath, "");
109
+ writeFileSync(stderrPath, `spawn error: ${message}\n`);
110
+ const result = {
111
+ id,
112
+ runtime: request.runtime,
113
+ exit_code: null,
114
+ signal: null,
115
+ timed_out: false,
116
+ duration_ms: duration,
117
+ stdout_bytes: 0,
118
+ stderr_bytes: Buffer.byteLength(`spawn error: ${message}\n`),
119
+ stdout_truncated: false,
120
+ stderr_truncated: false,
121
+ stdout_path: stdoutPath,
122
+ stderr_path: stderrPath,
123
+ meta_path: metaPath,
124
+ digest: `[spawn error: ${message}]`,
125
+ };
126
+ writeMeta(metaPath, result, request, now);
127
+ resolveP(result);
128
+ return;
129
+ }
130
+ const stdoutChunks = [];
131
+ const stderrChunks = [];
132
+ let stdoutBytes = 0;
133
+ let stderrBytes = 0;
134
+ let stdoutTruncated = false;
135
+ let stderrTruncated = false;
136
+ child.stdout?.on("data", (chunk) => {
137
+ const remaining = opts.stdout_cap_bytes - stdoutBytes;
138
+ if (remaining <= 0) {
139
+ stdoutTruncated = true;
140
+ return;
141
+ }
142
+ if (chunk.length <= remaining) {
143
+ stdoutChunks.push(chunk);
144
+ stdoutBytes += chunk.length;
145
+ }
146
+ else {
147
+ stdoutChunks.push(chunk.subarray(0, remaining));
148
+ stdoutBytes += remaining;
149
+ stdoutTruncated = true;
150
+ }
151
+ });
152
+ child.stderr?.on("data", (chunk) => {
153
+ const remaining = opts.stderr_cap_bytes - stderrBytes;
154
+ if (remaining <= 0) {
155
+ stderrTruncated = true;
156
+ return;
157
+ }
158
+ if (chunk.length <= remaining) {
159
+ stderrChunks.push(chunk);
160
+ stderrBytes += chunk.length;
161
+ }
162
+ else {
163
+ stderrChunks.push(chunk.subarray(0, remaining));
164
+ stderrBytes += remaining;
165
+ stderrTruncated = true;
166
+ }
167
+ });
168
+ let timedOut = false;
169
+ const timer = setTimeout(() => {
170
+ timedOut = true;
171
+ if (useProcessGroup && child.pid != null) {
172
+ try {
173
+ process.kill(-child.pid, "SIGKILL");
174
+ }
175
+ catch {
176
+ child.kill("SIGKILL");
177
+ }
178
+ }
179
+ else {
180
+ child.kill("SIGKILL");
181
+ }
182
+ }, timeoutMs);
183
+ timer.unref?.();
184
+ const finalize = (exitCode, signal) => {
185
+ clearTimeout(timer);
186
+ const duration = Date.now() - started;
187
+ const stdoutBuf = Buffer.concat(stdoutChunks);
188
+ const stderrBuf = Buffer.concat(stderrChunks);
189
+ const stdoutSuffix = stdoutTruncated ? "\n[truncated: stdout cap reached]\n" : "";
190
+ const stderrSuffix = stderrTruncated ? "\n[truncated: stderr cap reached]\n" : "";
191
+ writeFileSync(stdoutPath, Buffer.concat([stdoutBuf, Buffer.from(stdoutSuffix, "utf-8")]));
192
+ writeFileSync(stderrPath, Buffer.concat([stderrBuf, Buffer.from(stderrSuffix, "utf-8")]));
193
+ const digestBody = tail(stdoutBuf, opts.digest_chars);
194
+ const digest = digestBody.length > 0
195
+ ? digestBody
196
+ : timedOut
197
+ ? "[no stdout — timed out]"
198
+ : stderrBuf.length > 0
199
+ ? `[no stdout — tail of stderr]\n${tail(stderrBuf, opts.digest_chars)}`
200
+ : "[no output]";
201
+ const result = {
202
+ id,
203
+ runtime: request.runtime,
204
+ exit_code: exitCode,
205
+ signal,
206
+ timed_out: timedOut,
207
+ duration_ms: duration,
208
+ stdout_bytes: stdoutBytes,
209
+ stderr_bytes: stderrBytes,
210
+ stdout_truncated: stdoutTruncated,
211
+ stderr_truncated: stderrTruncated,
212
+ stdout_path: stdoutPath,
213
+ stderr_path: stderrPath,
214
+ meta_path: metaPath,
215
+ digest,
216
+ };
217
+ writeMeta(metaPath, result, request, now);
218
+ resolveP(result);
219
+ };
220
+ child.on("error", (err) => {
221
+ const message = err instanceof Error ? err.message : String(err);
222
+ const line = `child error: ${message}\n`;
223
+ const remaining = opts.stderr_cap_bytes - stderrBytes;
224
+ if (remaining > 0) {
225
+ const chunk = Buffer.from(line, "utf-8").subarray(0, remaining);
226
+ stderrChunks.push(chunk);
227
+ stderrBytes += chunk.length;
228
+ if (chunk.length < Buffer.byteLength(line, "utf-8"))
229
+ stderrTruncated = true;
230
+ }
231
+ });
232
+ child.on("close", (code, signal) => finalize(code, signal));
233
+ });
234
+ }
235
+ function defaultGenerateId() {
236
+ return randomUUID();
237
+ }
238
+ function writeMeta(path, result, request, now) {
239
+ const meta = {
240
+ id: result.id,
241
+ runtime: result.runtime,
242
+ purpose: request.purpose ?? null,
243
+ script_chars: request.script.length,
244
+ started_at: now.toISOString(),
245
+ finished_at: new Date(now.getTime() + result.duration_ms).toISOString(),
246
+ exit_code: result.exit_code,
247
+ signal: result.signal,
248
+ timed_out: result.timed_out,
249
+ duration_ms: result.duration_ms,
250
+ stdout_bytes: result.stdout_bytes,
251
+ stderr_bytes: result.stderr_bytes,
252
+ stdout_truncated: result.stdout_truncated,
253
+ stderr_truncated: result.stderr_truncated,
254
+ stdout_path: result.stdout_path,
255
+ stderr_path: result.stderr_path,
256
+ };
257
+ writeFileSync(path, `${JSON.stringify(meta, null, 2)}\n`);
258
+ }
@@ -495,7 +495,9 @@ function initSchema(db, fileBacked) {
495
495
  // that fresh installs already have. Add only columns needed by bootstrap
496
496
  // indexes so old DBs can open far enough for the normal migration chain.
497
497
  ensureBootstrapIndexColumns(db);
498
- db.exec("CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope)");
498
+ if (columnExists(db, "memories", "scope")) {
499
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope)");
500
+ }
499
501
  db.exec("CREATE INDEX IF NOT EXISTS idx_memory_sources_kind ON memory_sources(kind)");
500
502
  db.exec("CREATE INDEX IF NOT EXISTS idx_memory_sources_scope ON memory_sources(scope)");
501
503
  db.exec("CREATE INDEX IF NOT EXISTS idx_memory_relations_from ON memory_relations(from_id)");
@@ -1097,6 +1099,8 @@ let currentPath = null;
1097
1099
  let currentPid = 0;
1098
1100
  let _exitHandlerRegistered = false;
1099
1101
  let _dbOpenAttempted = false;
1102
+ let _lastDbError = null;
1103
+ let _lastDbPhase = null;
1100
1104
  export function getDbProvider() {
1101
1105
  loadProvider();
1102
1106
  return providerName;
@@ -1113,13 +1117,54 @@ export function isDbAvailable() {
1113
1117
  export function wasDbOpenAttempted() {
1114
1118
  return _dbOpenAttempted;
1115
1119
  }
1120
+ export function getDbStatus() {
1121
+ loadProvider();
1122
+ return {
1123
+ available: currentDb !== null,
1124
+ provider: providerName,
1125
+ attempted: _dbOpenAttempted,
1126
+ lastError: _lastDbError,
1127
+ lastPhase: _lastDbPhase,
1128
+ };
1129
+ }
1116
1130
  export function openDatabase(path) {
1117
1131
  _dbOpenAttempted = true;
1118
1132
  if (currentDb && currentPath !== path)
1119
1133
  closeDatabase();
1120
1134
  if (currentDb && currentPath === path)
1121
1135
  return true;
1122
- const rawDb = openRawDb(path);
1136
+ // Reset error state only when a new open attempt is actually going to run.
1137
+ _lastDbError = null;
1138
+ _lastDbPhase = null;
1139
+ let rawDb;
1140
+ let fallbackProvider = null;
1141
+ let fallbackModule = null;
1142
+ try {
1143
+ rawDb = openRawDb(path);
1144
+ }
1145
+ catch (primaryErr) {
1146
+ _lastDbPhase = "open";
1147
+ _lastDbError = primaryErr instanceof Error ? primaryErr : new Error(String(primaryErr));
1148
+ // node:sqlite loaded but failed to open this file — try better-sqlite3 as fallback.
1149
+ if (providerName === "node:sqlite") {
1150
+ try {
1151
+ const mod = _require("better-sqlite3");
1152
+ const Db = (mod && mod.default) ? mod.default : mod;
1153
+ if (typeof Db === "function") {
1154
+ rawDb = new Db(path);
1155
+ fallbackProvider = "better-sqlite3";
1156
+ fallbackModule = Db;
1157
+ _lastDbError = null;
1158
+ _lastDbPhase = null;
1159
+ }
1160
+ }
1161
+ catch {
1162
+ // fallback unavailable; surface original error
1163
+ }
1164
+ }
1165
+ if (!rawDb)
1166
+ throw primaryErr;
1167
+ }
1123
1168
  if (!rawDb)
1124
1169
  return false;
1125
1170
  const adapter = createAdapter(rawDb);
@@ -1137,6 +1182,8 @@ export function openDatabase(path) {
1137
1182
  process.stderr.write("gsd-db: recovered corrupt database via VACUUM\n");
1138
1183
  }
1139
1184
  catch (retryErr) {
1185
+ _lastDbPhase = "vacuum-recovery";
1186
+ _lastDbError = retryErr instanceof Error ? retryErr : new Error(String(retryErr));
1140
1187
  try {
1141
1188
  adapter.close();
1142
1189
  }
@@ -1147,15 +1194,22 @@ export function openDatabase(path) {
1147
1194
  }
1148
1195
  }
1149
1196
  else {
1197
+ _lastDbPhase = "initSchema";
1198
+ _lastDbError = err instanceof Error ? err : new Error(String(err));
1150
1199
  try {
1151
1200
  adapter.close();
1152
1201
  }
1153
1202
  catch (e) {
1154
- logWarning("db", `close after VACUUM failed: ${e.message}`);
1203
+ logWarning("db", `close after initSchema failed: ${e.message}`);
1155
1204
  }
1156
1205
  throw err;
1157
1206
  }
1158
1207
  }
1208
+ // Commit fallback provider switch only after open + schema both succeeded.
1209
+ if (fallbackProvider) {
1210
+ providerName = fallbackProvider;
1211
+ providerModule = fallbackModule;
1212
+ }
1159
1213
  currentDb = adapter;
1160
1214
  currentPath = path;
1161
1215
  currentPid = process.pid;
@@ -1194,8 +1248,12 @@ export function closeDatabase() {
1194
1248
  currentDb = null;
1195
1249
  currentPath = null;
1196
1250
  currentPid = 0;
1197
- _dbOpenAttempted = false;
1198
1251
  }
1252
+ // Reset session-scoped state unconditionally so stale error info from a
1253
+ // failed open doesn't persist into the next open attempt or status check.
1254
+ _dbOpenAttempted = false;
1255
+ _lastDbError = null;
1256
+ _lastDbPhase = null;
1199
1257
  }
1200
1258
  /** Run a full VACUUM — call sparingly (e.g. after milestone completion). */
1201
1259
  export function vacuumDatabase() {