jeo-code 0.12.12 → 0.12.15

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 (63) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.ja.md +33 -9
  3. package/README.ko.md +33 -9
  4. package/README.md +33 -9
  5. package/README.zh.md +33 -9
  6. package/package.json +4 -2
  7. package/src/agent/browser-tool.ts +9 -1
  8. package/src/agent/config-schema.ts +8 -0
  9. package/src/agent/context-files.ts +19 -0
  10. package/src/agent/engine.ts +25 -6
  11. package/src/agent/eval-tool.ts +6 -0
  12. package/src/agent/goal-verifier.ts +30 -6
  13. package/src/agent/hooks.ts +52 -4
  14. package/src/agent/job-registry.ts +110 -18
  15. package/src/agent/job-tool.ts +43 -9
  16. package/src/agent/loop-guards.ts +17 -2
  17. package/src/agent/memory.ts +151 -112
  18. package/src/agent/monitor-registry.ts +142 -30
  19. package/src/agent/monitor-tool.ts +34 -6
  20. package/src/agent/notify/discord-daemon.ts +135 -42
  21. package/src/agent/notify/session-endpoint.ts +27 -5
  22. package/src/agent/sandbox/assets.d.ts +4 -0
  23. package/src/agent/sandbox/authority.ts +295 -0
  24. package/src/agent/sandbox/bundle.ts +37 -0
  25. package/src/agent/sandbox/control.ts +49 -0
  26. package/src/agent/sandbox/docker.ts +283 -0
  27. package/src/agent/sandbox/hands-source.text +14548 -0
  28. package/src/agent/sandbox/hands-worker.ts +168 -0
  29. package/src/agent/sandbox/policy.ts +155 -0
  30. package/src/agent/sandbox/rpc.ts +90 -0
  31. package/src/agent/sandbox/supervisor.ts +210 -0
  32. package/src/agent/sandbox/types.ts +77 -0
  33. package/src/agent/shell-lease.ts +190 -0
  34. package/src/agent/state.ts +167 -0
  35. package/src/agent/subagents.ts +4 -3
  36. package/src/agent/task-tool.ts +8 -0
  37. package/src/agent/todo-tool.ts +30 -1
  38. package/src/agent/tool-schemas.ts +13 -0
  39. package/src/ai/model-discovery.ts +12 -0
  40. package/src/ai/providers/anthropic.ts +27 -2
  41. package/src/ai/providers/antigravity.ts +10 -2
  42. package/src/ai/providers/gemini.ts +20 -16
  43. package/src/ai/providers/ollama.ts +14 -0
  44. package/src/ai/providers/openai-responses.ts +22 -5
  45. package/src/ai/providers/openai.ts +23 -40
  46. package/src/cli/runner.ts +3 -0
  47. package/src/commands/approve.ts +37 -13
  48. package/src/commands/launch/flags.ts +2 -1
  49. package/src/commands/launch/jobs-slash.ts +1 -1
  50. package/src/commands/launch.ts +261 -35
  51. package/src/commands/messenger.ts +105 -39
  52. package/src/commands/notify.ts +2 -2
  53. package/src/commands/team.ts +44 -12
  54. package/src/commands/ultragoal.ts +6 -16
  55. package/src/messenger/activity.ts +2 -1
  56. package/src/messenger/config.ts +51 -9
  57. package/src/messenger/discord-setup.ts +122 -20
  58. package/src/messenger/discord.ts +39 -52
  59. package/src/messenger/gateway.ts +33 -10
  60. package/src/messenger/model-selection.ts +101 -0
  61. package/src/messenger/session-pool.ts +189 -35
  62. package/src/messenger/types.ts +3 -2
  63. package/src/skills/catalog.ts +207 -0
@@ -73,8 +73,9 @@ export const GUARD_LIMITS = Object.freeze({
73
73
  * filenames, prose, and shell output from impersonating verification evidence.
74
74
  */
75
75
  export const VERIFY_SIGNAL_RE = /^(?:(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?(?:test|tests|typecheck|lint|build|check|spec|tsc|eslint|vitest|jest)(?::[\w-]+)?|(?:npx|bunx)\s+(?:tsc|eslint|vitest|jest|pytest)|(?:tsc|eslint|vitest|jest|pytest)|(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:-m\s+)?pytest|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|(?:make|just)\s+(?:test|check|lint|build|typecheck))(?:\s+[A-Za-z0-9_./:@+*?,=-]+)*$/i;
76
- const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--show-?config|--init|--dry-run|--no-run|--passWithNoTests)(?=$|\s|=)/i;
76
+ const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--init|--dry-run|--no-run|--passWithNoTests|--if-present)(?=$|\s|=)/i;
77
77
  const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
78
+ const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
78
79
 
79
80
  /**
80
81
  * True only when the command itself is a recognized verifier invocation. Output
@@ -82,7 +83,21 @@ const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-
82
83
  */
83
84
  export function isVerificationSignal(cmd: string): boolean {
84
85
  const command = cmd.trim();
85
- return !/[;&|<>`$(){}\n\r]/.test(command) && VERIFY_SIGNAL_RE.test(command) && !NONEXECUTING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_FLAG_RE.test(command);
86
+ const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(command)
87
+ || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(command);
88
+ return !shortNoOp && !/[;&|<>`$(){}\n\r]/.test(command) && VERIFY_SIGNAL_RE.test(command) && !NONEXECUTING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_SCRIPT_RE.test(command);
89
+ }
90
+
91
+ /** Require test-run evidence for Go and Cargo's recognized text output. */
92
+ export function verificationProducedEvidence(cmd: string, output: string): boolean {
93
+ if (/^go\s+test(?:\s|$)/.test(cmd.trim())) {
94
+ const summaries = output.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
95
+ return summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line));
96
+ }
97
+ if (/^cargo\s+test(?:\s|$)/.test(cmd.trim())) {
98
+ return [...output.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0);
99
+ }
100
+ return true;
86
101
  }
87
102
 
88
103
  /**
@@ -19,6 +19,15 @@ import { parseConcept, serializeConcept, slugify, isReservedFile, conceptId } fr
19
19
  import { tryExtractJsonObject } from "./json";
20
20
  import { buildConceptGraph, expandByGraph, lintConceptGraph, type ConceptGraph, type GraphLintReport } from "./memory-graph";
21
21
  import { ensureJeoGitignore } from "./state";
22
+ import { currentSandboxExecution } from "./hooks";
23
+
24
+ async function sandboxMemory<T>(action: string, cwd: string, args: Record<string, unknown> = {}): Promise<T> {
25
+ const sandbox = currentSandboxExecution();
26
+ if (!sandbox) throw new Error("SANDBOX_MEMORY: missing execution scope");
27
+ const result = await sandbox.supervisor.call("__workflow", { operation: "memory", action, ...args }, { cwd, signal: sandbox.signal });
28
+ if (!result.success) throw new Error(result.error || "SANDBOX_MEMORY: operation failed");
29
+ return JSON.parse(result.output) as T;
30
+ }
22
31
 
23
32
  /** On-disk document cap — the distill prompt instructs the model to stay under it. */
24
33
  export const MEMORY_MAX_CHARS = 6_000;
@@ -47,6 +56,7 @@ export function memoryFilePath(cwd: string): string {
47
56
  }
48
57
 
49
58
  export async function loadMemory(cwd: string): Promise<string> {
59
+ if (currentSandboxExecution()) return sandboxMemory("loadMemory", cwd);
50
60
  try {
51
61
  return (await fs.readFile(memoryFilePath(cwd), "utf-8")).trim();
52
62
  } catch {
@@ -134,6 +144,7 @@ export interface MigrationResult {
134
144
  * so the active path is the bundle while a rollback copy survives.
135
145
  */
136
146
  export async function migrateLegacyMemory(cwd: string): Promise<MigrationResult> {
147
+ if (currentSandboxExecution()) return sandboxMemory("migrate", cwd);
137
148
  const bundleDir = path.join(cwd, ".jeo", "memory");
138
149
  // Idempotent: an existing concept bundle wins — never double-migrate.
139
150
  if ((await loadConcepts(cwd)).length > 0) {
@@ -301,6 +312,7 @@ export function invalidateConceptCache(): void {
301
312
  * files (index.md/log.md) and raw/ payloads are skipped; unparseable or
302
313
  * frontmatter-less files are ignored (lenient consumption). */
303
314
  export async function loadConcepts(cwd: string): Promise<Concept[]> {
315
+ if (currentSandboxExecution()) return sandboxMemory("loadConcepts", cwd);
304
316
  return loadConceptsFromBundle(path.join(cwd, ".jeo", "memory"));
305
317
  }
306
318
 
@@ -672,11 +684,12 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
672
684
  * The memory text is MODEL-DISTILLED from session transcripts (which include tool
673
685
  * outputs — file contents, web results), so it is injection-hardened like subagent
674
686
  * reports: tag-breakout sequences are neutralized and the block is framed as DATA. */
675
- export async function memoryPromptSection(cwd: string, query?: string): Promise<string> {
687
+ export async function memoryPromptSection(cwd: string, query?: string, legacy: boolean = jeoEnv("MEMORY_LEGACY") === "1"): Promise<string> {
676
688
  if (jeoEnv("NO_MEMORY") === "1") return "";
689
+ if (currentSandboxExecution()) return sandboxMemory("prompt", cwd, { query, legacy });
677
690
  // Rollback toggle (Sprint 05): JEO_MEMORY_LEGACY=1 forces the legacy single-doc
678
691
  // path, ignoring any concept bundle — reads MEMORY.md, or its migration backup.
679
- if (jeoEnv("MEMORY_LEGACY") === "1") {
692
+ if (legacy) {
680
693
  let memory = await loadMemory(cwd);
681
694
  if (!memory) memory = (await fs.readFile(`${memoryFilePath(cwd)}.bak`, "utf-8").catch(() => "")).trim();
682
695
  return memory ? frameMemory(memory) : "";
@@ -833,7 +846,8 @@ async function updateLog(bundleDir: string, updatedConcepts: { title: string; ty
833
846
  await fs.rename(tmpPath, logPath);
834
847
  }
835
848
 
836
- export async function saveRawPayload(bundleDir: string, payload: any): Promise<void> {
849
+ export async function saveRawPayload(bundleDir: string, payload: unknown): Promise<void> {
850
+ if (currentSandboxExecution()) throw new Error("SANDBOX_MEMORY: raw session payloads are parent-only");
837
851
  const rawDir = path.join(bundleDir, "raw");
838
852
  await fs.mkdir(rawDir, { recursive: true });
839
853
  const filename = `session-${Date.now()}-${process.pid}.json`;
@@ -909,6 +923,118 @@ async function upsertConceptFile(
909
923
  return relPath;
910
924
  }
911
925
 
926
+ interface DistillInput { existingConcepts: Record<string, unknown>[] }
927
+
928
+ async function loadDistillInput(cwd: string): Promise<DistillInput> {
929
+ if (currentSandboxExecution()) return sandboxMemory("distillInput", cwd);
930
+ const bundleDir = path.join(cwd, ".jeo", "memory");
931
+ const existingConcepts: Record<string, unknown>[] = [];
932
+ for (const file of await findMarkdownFiles(bundleDir)) {
933
+ const relPath = path.relative(bundleDir, file);
934
+ if (isReservedFile(relPath)) continue;
935
+ try {
936
+ const parsed = parseConcept(await fs.readFile(file, "utf-8"));
937
+ if (!parsed.hasFrontmatter) continue;
938
+ existingConcepts.push({
939
+ type: parsed.frontmatter.type,
940
+ title: parsed.frontmatter.title || "",
941
+ description: parsed.frontmatter.description,
942
+ body: parsed.body,
943
+ tags: parsed.frontmatter.tags,
944
+ confidence: parsed.frontmatter.confidence,
945
+ links: parsed.frontmatter.links,
946
+ path: relPath,
947
+ });
948
+ } catch {}
949
+ }
950
+ return { existingConcepts };
951
+ }
952
+
953
+ async function applyDistilledMemory(cwd: string, distilled: string, existingCount: number, legacyDoc: boolean, verified: boolean): Promise<DistillResult> {
954
+ if (currentSandboxExecution()) return sandboxMemory("applyDistilled", cwd, { distilled, existingCount, legacyDoc, verified });
955
+ const bundleDir = path.join(cwd, ".jeo", "memory");
956
+ const parsedJson = tryExtractJsonObject<{ concepts?: unknown }>(distilled);
957
+ if (parsedJson && Array.isArray(parsedJson.concepts)) {
958
+ await fs.mkdir(bundleDir, { recursive: true });
959
+ await ensureJeoGitignore(cwd);
960
+ const updatedConcepts: { title: string; type: string }[] = [];
961
+ for (const raw of parsedJson.concepts) {
962
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
963
+ const concept = raw as Record<string, unknown>;
964
+ const type = typeof concept.type === "string" ? concept.type.trim() : "";
965
+ const title = typeof concept.title === "string" ? concept.title.trim() : "";
966
+ if (!type || !title) continue;
967
+ try {
968
+ await upsertConceptFile(bundleDir, {
969
+ type,
970
+ title,
971
+ description: typeof concept.description === "string" ? concept.description : "",
972
+ body: typeof concept.body === "string" ? concept.body : "",
973
+ tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
974
+ confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
975
+ links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
976
+ }, { verified });
977
+ updatedConcepts.push({ title, type });
978
+ } catch {
979
+ // Preserve valid learnings when one concept cannot be stored.
980
+ }
981
+ }
982
+ await withIndexLock(bundleDir, async () => {
983
+ await rebuildIndex(bundleDir);
984
+ if (updatedConcepts.length > 0) await updateLog(bundleDir, updatedConcepts);
985
+ });
986
+ if (legacyDoc) await fs.rename(memoryFilePath(cwd), `${memoryFilePath(cwd)}.bak`).catch(() => {});
987
+ await cleanupStalePendingFiles(bundleDir);
988
+ invalidateConceptCache();
989
+ return { updated: true };
990
+ }
991
+ if (existingCount > 0) return { updated: false, skipped: "distill produced no JSON; kept existing concept bundle (legacy blob suppressed)" };
992
+ const doc = distilled.trim().slice(0, MEMORY_MAX_CHARS);
993
+ if (!doc) return { updated: false, skipped: "model returned an empty document" };
994
+ const file = memoryFilePath(cwd);
995
+ await fs.mkdir(path.dirname(file), { recursive: true });
996
+ const tmp = `${file}.tmp-${process.pid}`;
997
+ await fs.writeFile(tmp, doc + "\n", "utf-8");
998
+ await fs.rename(tmp, file);
999
+ invalidateConceptCache();
1000
+ return { updated: true };
1001
+ }
1002
+
1003
+ /** Guest-only deterministic operations; provider calls never cross this boundary. */
1004
+ export async function executeMemoryOperation(action: string, args: Record<string, unknown>, cwd: string): Promise<unknown> {
1005
+ if (currentSandboxExecution()) throw new Error("SANDBOX_MEMORY: guest handler cannot run in the brain");
1006
+ switch (action) {
1007
+ case "loadMemory": return loadMemory(cwd);
1008
+ case "loadConcepts": return loadConcepts(cwd);
1009
+ case "prompt": return memoryPromptSection(cwd, typeof args.query === "string" ? args.query : undefined, args.legacy === true);
1010
+ case "migrate": return migrateLegacyMemory(cwd);
1011
+ case "distillInput": return loadDistillInput(cwd);
1012
+ case "record": {
1013
+ if (!args.attempt || typeof args.attempt !== "object" || !("title" in args.attempt) || typeof args.attempt.title !== "string") throw new Error("Invalid memory attempt");
1014
+ const attempt = args.attempt;
1015
+ return recordFailedAttempt(cwd, {
1016
+ title: args.attempt.title,
1017
+ description: "description" in attempt && typeof attempt.description === "string" ? attempt.description : undefined,
1018
+ body: "body" in attempt && typeof attempt.body === "string" ? attempt.body : undefined,
1019
+ tags: "tags" in attempt && Array.isArray(attempt.tags) ? attempt.tags.filter((tag): tag is string => typeof tag === "string") : undefined,
1020
+ });
1021
+ }
1022
+ case "applyDistilled": {
1023
+ if (typeof args.distilled !== "string" || typeof args.existingCount !== "number" || !Number.isSafeInteger(args.existingCount) || args.existingCount < 0) throw new Error("Invalid memory distill result");
1024
+ return applyDistilledMemory(cwd, args.distilled, args.existingCount, args.legacyDoc === true, args.verified === true);
1025
+ }
1026
+ case "readPayload":
1027
+ case "removePayload": {
1028
+ if (typeof args.name !== "string" || args.name !== path.basename(args.name)) throw new Error("Invalid memory payload name");
1029
+ const file = path.join(cwd, args.name);
1030
+ if (action === "readPayload") return fs.readFile(file, "utf-8");
1031
+ await fs.unlink(file).catch(() => {});
1032
+ return null;
1033
+ }
1034
+ default: throw new Error("Unknown memory operation");
1035
+ }
1036
+ }
1037
+
912
1038
  export async function distillSessionMemory(
913
1039
  history: Message[],
914
1040
  cwd: string,
@@ -918,30 +1044,7 @@ export async function distillSessionMemory(
918
1044
  const body = history.filter(m => m.role !== "system");
919
1045
  if (body.length < MIN_HISTORY_MESSAGES) return { updated: false, skipped: "session too short" };
920
1046
  try {
921
- const bundleDir = path.join(cwd, ".jeo", "memory");
922
- const existingConcepts: any[] = [];
923
- try {
924
- const files = await findMarkdownFiles(bundleDir);
925
- for (const file of files) {
926
- const relPath = path.relative(bundleDir, file);
927
- if (isReservedFile(relPath)) continue;
928
- try {
929
- const content = await fs.readFile(file, "utf-8");
930
- const parsed = parseConcept(content);
931
- if (!parsed.hasFrontmatter) continue; // skip MEMORY.md and other legacy blobs
932
- existingConcepts.push({
933
- type: parsed.frontmatter.type,
934
- title: parsed.frontmatter.title || "",
935
- description: parsed.frontmatter.description,
936
- body: parsed.body,
937
- tags: parsed.frontmatter.tags,
938
- confidence: parsed.frontmatter.confidence,
939
- links: parsed.frontmatter.links,
940
- path: relPath,
941
- });
942
- } catch {}
943
- }
944
- } catch {}
1047
+ const { existingConcepts } = await loadDistillInput(cwd);
945
1048
  // A lingering single-doc MEMORY.md (legacy, or a prior text-only bootstrap
946
1049
  // fallback) is NOT a concept document — loadConcepts ignores it and it breaks
947
1050
  // OKF conformance. Feed its content into the merge context so its learnings
@@ -994,86 +1097,9 @@ export async function distillSessionMemory(
994
1097
  new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`memory distill timed out after ${timeoutMs}ms`)), timeoutMs)),
995
1098
  ]);
996
1099
 
997
- // Robust extraction: the distill prompt requests JSON, but text-only providers
998
- // (the default antigravity backend) routinely wrap it in prose or fences.
999
- // tryExtractJsonObject recovers the first balanced {...}, tolerating that noise;
1000
- // a null result means the model gave plain text → old MEMORY.md fallback below.
1001
- const parsedJson = tryExtractJsonObject<{ concepts?: unknown }>(distilled);
1002
-
1003
-
1004
- if (parsedJson && Array.isArray(parsedJson.concepts)) {
1005
- await fs.mkdir(bundleDir, { recursive: true });
1006
- await ensureJeoGitignore(cwd);
1007
- const updatedConcepts: { title: string; type: string }[] = [];
1008
-
1009
- for (const raw of parsedJson.concepts) {
1010
- // A text-only / small model (the default antigravity backend) can emit
1011
- // stray non-object array elements (null, strings, numbers) or non-string
1012
- // type/title fields. Validate each element and isolate per-concept failures:
1013
- // one malformed concept must NEVER throw out of the loop, because the outer
1014
- // catch would then discard every valid learning distilled in this run.
1015
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
1016
- const concept = raw as {
1017
- type?: unknown; title?: unknown; description?: unknown; body?: unknown;
1018
- tags?: unknown; confidence?: unknown; links?: unknown;
1019
- };
1020
- const type = typeof concept.type === "string" ? concept.type.trim() : "";
1021
- const title = typeof concept.title === "string" ? concept.title.trim() : "";
1022
- if (!type || !title) continue;
1023
- try {
1024
- await upsertConceptFile(bundleDir, {
1025
- type, // unknown types fall back to facts/ inside the helper (lenient)
1026
- title,
1027
- description: typeof concept.description === "string" ? concept.description : "",
1028
- body: typeof concept.body === "string" ? concept.body : "",
1029
- tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
1030
- confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
1031
- links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
1032
- }, { verified: opts.sessionVerified === true });
1033
- updatedConcepts.push({ title, type });
1034
- } catch {
1035
- // Skip just this concept; keep distilling the rest of the batch.
1036
- }
1037
- }
1038
-
1039
- await withIndexLock(bundleDir, async () => {
1040
- await rebuildIndex(bundleDir);
1041
- if (updatedConcepts.length > 0) {
1042
- await updateLog(bundleDir, updatedConcepts);
1043
- }
1044
- });
1045
- // Concepts now own the durable memory and the legacy blob (if any) was
1046
- // folded into the merge above. Archive a lingering MEMORY.md off the active
1047
- // read path so it can never break OKF conformance or shadow the bundle.
1048
- if (legacyDoc) {
1049
- await fs.rename(memoryFilePath(cwd), `${memoryFilePath(cwd)}.bak`).catch(() => {});
1050
- }
1051
- await cleanupStalePendingFiles(bundleDir);
1052
- invalidateConceptCache();
1053
- return { updated: true };
1054
- } else {
1055
- // JSON extraction failed (text-only models often wrap or drop the JSON).
1056
- // The single-doc MEMORY.md is a BOOTSTRAP fallback only: it is injected by
1057
- // memoryPromptSection ONLY when the OKF concept bundle is empty. Writing it
1058
- // while concepts already exist would (a) be silently ignored at injection
1059
- // (concepts win) — losing the learning — and (b) break OKF conformance
1060
- // (MEMORY.md has no frontmatter). So when a bundle exists, do NOT clobber
1061
- // it with a dead blob; keep the prior concepts as the durable memory.
1062
- if (existingConcepts.length > 0) {
1063
- return { updated: false, skipped: "distill produced no JSON; kept existing concept bundle (legacy blob suppressed)" };
1064
- }
1065
- const doc = distilled.trim().slice(0, MEMORY_MAX_CHARS);
1066
- if (!doc) return { updated: false, skipped: "model returned an empty document" };
1067
- const file = memoryFilePath(cwd);
1068
- await fs.mkdir(path.dirname(file), { recursive: true });
1069
- const tmp = `${file}.tmp-${process.pid}`;
1070
- await fs.writeFile(tmp, doc + "\n", "utf-8");
1071
- await fs.rename(tmp, file);
1072
- invalidateConceptCache();
1073
- return { updated: true };
1074
- }
1075
- } catch (err: any) {
1076
- return { updated: false, skipped: `distill failed: ${err?.message ?? String(err)}` };
1100
+ return await applyDistilledMemory(cwd, distilled, existingConcepts.length, !!legacyDoc, opts.sessionVerified === true);
1101
+ } catch (err: unknown) {
1102
+ return { updated: false, skipped: `distill failed: ${err instanceof Error ? err.message : String(err)}` };
1077
1103
  }
1078
1104
  }
1079
1105
 
@@ -1092,6 +1118,7 @@ export async function recordFailedAttempt(
1092
1118
  attempt: { title: string; description?: string; body?: string; tags?: string[] },
1093
1119
  ): Promise<{ recorded: boolean; skipped?: string }> {
1094
1120
  if (jeoEnv("NO_MEMORY") === "1") return { recorded: false, skipped: "disabled (JEO_NO_MEMORY=1)" };
1121
+ if (currentSandboxExecution()) return sandboxMemory("record", cwd, { attempt });
1095
1122
  const title = attempt.title?.trim();
1096
1123
  if (!title) return { recorded: false, skipped: "empty title" };
1097
1124
  try {
@@ -1143,8 +1170,8 @@ export function distillInvocation(argv1: string | undefined, execPath: string, c
1143
1170
 
1144
1171
  type SpawnLike = (opts: { cmd: string[]; cwd: string; stdin: "ignore"; stdout: "ignore"; stderr: "ignore" }) => { unref(): void };
1145
1172
 
1146
- /** Write the payload and hand distillation to a detached child. Returns true when
1147
- * a child was spawned. Best-effort: failure means no memory update, never a slow exit. */
1173
+ /** Detached in nonsandbox mode. Sandboxed learning must finish before its owning
1174
+ * supervisor closes; credentials stay in brain and file effects stay in hands. */
1148
1175
  export async function spawnDetachedDistill(
1149
1176
  history: Message[],
1150
1177
  cwd: string,
@@ -1153,6 +1180,15 @@ export async function spawnDetachedDistill(
1153
1180
  ): Promise<boolean> {
1154
1181
  if (jeoEnv("NO_MEMORY") === "1") return false;
1155
1182
  if (history.filter(m => m.role !== "system").length < MIN_HISTORY_MESSAGES) return false;
1183
+ if (currentSandboxExecution()) {
1184
+ try {
1185
+ // The transcript and system prompt never enter the shared guest workspace.
1186
+ // Only the derived learning document crosses the deterministic apply RPC.
1187
+ return (await distillSessionMemory(history, cwd, { model })).updated;
1188
+ } catch {
1189
+ return false;
1190
+ }
1191
+ }
1156
1192
  try {
1157
1193
  const dir = path.join(cwd, ".jeo", "memory");
1158
1194
  await fs.mkdir(dir, { recursive: true });
@@ -1188,17 +1224,20 @@ export async function runMemoryDistillCommand(
1188
1224
  const payloadPath = (args[0] ?? "").trim();
1189
1225
  if (!payloadPath) return;
1190
1226
  try {
1191
- const payloadContent = await fs.readFile(payloadPath, "utf-8");
1227
+ const payloadContent = currentSandboxExecution()
1228
+ ? await sandboxMemory<string>("readPayload", path.dirname(path.resolve(payloadPath)), { name: path.basename(payloadPath) })
1229
+ : await fs.readFile(payloadPath, "utf-8");
1192
1230
  const payload = JSON.parse(payloadContent) as { model?: string; messages?: Message[] };
1193
1231
  const bundleDir = path.join(process.cwd(), ".jeo", "memory");
1194
- await saveRawPayload(bundleDir, payload);
1232
+ if (!currentSandboxExecution()) await saveRawPayload(bundleDir, payload);
1195
1233
  if (Array.isArray(payload.messages)) {
1196
1234
  await distillSessionMemory(payload.messages, process.cwd(), { model: payload.model });
1197
1235
  }
1198
1236
  } catch {
1199
1237
  // best-effort — a broken payload must not leave error noise in a detached child
1200
1238
  } finally {
1201
- await fs.unlink(payloadPath).catch(() => {});
1239
+ if (currentSandboxExecution()) await sandboxMemory("removePayload", path.dirname(path.resolve(payloadPath)), { name: path.basename(payloadPath) });
1240
+ else await fs.unlink(payloadPath).catch(() => {});
1202
1241
  }
1203
1242
  } finally {
1204
1243
  // Guaranteed on every path (incl. the no-payload early return and a distill timeout):
@@ -8,6 +8,8 @@
8
8
  * `cancelAll()` is called during explicit session teardown.
9
9
  */
10
10
 
11
+ import { settlesWithin, spawnLeasedShell, type LeasedShell } from "./shell-lease";
12
+
11
13
  export type MonitorStatus = "running" | "exited" | "killed" | "failed";
12
14
 
13
15
  export interface MonitorRecord {
@@ -32,17 +34,34 @@ export interface MonitorRegistryOptions {
32
34
 
33
35
  interface Entry {
34
36
  record: MonitorRecord;
35
- proc?: Bun.Subprocess<"ignore", "pipe", "pipe">;
36
37
  promise: Promise<void>;
38
+ cancel?: () => boolean;
39
+ /** SIGKILL reached the pinned process group. */
40
+ killDelivered?: boolean;
41
+ /** Termination of every process holding the monitor's group or pipes cannot be confirmed. */
42
+ incomplete?: boolean;
43
+ }
44
+ interface NoticeQueue {
45
+ tails: [Promise<void>, Promise<void>];
46
+ count: number;
47
+ chars: number;
48
+ overflow: boolean;
49
+ firstLineClaimed: boolean;
37
50
  }
38
51
 
52
+
39
53
  const OUTPUT_CAP = 20_000;
40
54
  const TRUNCATION_MARKER = "[…output truncated…]\n";
55
+ const PIPE_LINGER_MS = 500;
56
+ const NOTICE_CHAR_LIMIT = 20_000;
57
+ const NOTICE_COUNT_LIMIT = 1000;
41
58
 
42
59
  export class MonitorRegistry {
43
60
  private readonly entries = new Map<string, Entry>();
44
61
  private readonly buffers = new Map<string, string>();
45
62
  private readonly truncated = new Set<string>();
63
+ private readonly active = new Set<string>();
64
+ private readonly pendingMutations = new Set<string>();
46
65
  private readonly onLine?: MonitorLineHandler;
47
66
  private seq = 0;
48
67
  constructor(options: MonitorRegistryOptions | MonitorLineHandler = {}) {
@@ -73,21 +92,45 @@ export class MonitorRegistry {
73
92
 
74
93
  private stopAfterFirstLine(entry: Entry): void {
75
94
  if (entry.record.status !== "running") return;
76
- entry.record.status = "killed";
95
+ const killed = entry.cancel?.();
96
+ entry.record.status = killed ? "killed" : "failed";
77
97
  entry.record.finishedAt = Date.now();
78
- try {
79
- entry.proc?.kill();
80
- } catch {
81
- // The process may have exited between the line event and kill().
82
- }
83
98
  }
84
99
 
85
- private async drain(entry: Entry, stream: ReadableStream<Uint8Array>): Promise<void> {
100
+ private async drain(entry: Entry, reader: { read: () => Promise<{ done?: boolean; value?: Uint8Array }> }, queue: NoticeQueue, index: 0 | 1): Promise<void> {
86
101
  const decoder = new TextDecoder();
87
102
  let pending = "";
103
+ const overflow = () => {
104
+ if (queue.overflow) return;
105
+ queue.overflow = true;
106
+ entry.cancel?.();
107
+ };
108
+ const queueLine = (line: string) => {
109
+ if (entry.record.status !== "running" || queue.overflow || (!entry.record.persistent && queue.firstLineClaimed)) return;
110
+ if (queue.count >= NOTICE_COUNT_LIMIT || queue.chars + line.length > NOTICE_CHAR_LIMIT) {
111
+ overflow();
112
+ return;
113
+ }
114
+ if (!entry.record.persistent) queue.firstLineClaimed = true;
115
+ queue.count++;
116
+ queue.chars += line.length;
117
+ queue.tails[index] = queue.tails[index].then(async () => {
118
+ try {
119
+ if (queue.overflow) return;
120
+ await this.emit(entry.record, line);
121
+ if (!entry.record.persistent) this.stopAfterFirstLine(entry);
122
+ } finally {
123
+ queue.count--;
124
+ queue.chars -= line.length;
125
+ }
126
+ });
127
+ };
88
128
  try {
89
- for await (const chunk of stream) {
90
- const text = decoder.decode(chunk, { stream: true });
129
+ while (true) {
130
+ const { done, value } = await reader.read();
131
+ if (done) break;
132
+ if (!value) continue;
133
+ const text = decoder.decode(value, { stream: true });
91
134
  this.append(entry.record.id, text);
92
135
  pending += text;
93
136
  while (true) {
@@ -96,12 +139,13 @@ export class MonitorRegistry {
96
139
  const line = pending.slice(0, match.index);
97
140
  pending = pending.slice(match.index + match[0].length);
98
141
  if (entry.record.status !== "running") return;
99
- await this.emit(entry.record, line);
100
- if (!entry.record.persistent) {
101
- this.stopAfterFirstLine(entry);
102
- return;
142
+ if (queue.count >= NOTICE_COUNT_LIMIT || queue.chars + line.length > NOTICE_CHAR_LIMIT) {
143
+ await new Promise<void>(resolve => setTimeout(resolve, 0));
103
144
  }
145
+ queueLine(line);
146
+ if (queue.overflow || !entry.record.persistent) return;
104
147
  }
148
+ if (pending.length > NOTICE_CHAR_LIMIT) { overflow(); return; }
105
149
  }
106
150
  const tail = decoder.decode();
107
151
  if (tail) {
@@ -109,8 +153,7 @@ export class MonitorRegistry {
109
153
  pending += tail;
110
154
  }
111
155
  if (pending.length > 0 && entry.record.status === "running") {
112
- await this.emit(entry.record, pending);
113
- if (!entry.record.persistent) this.stopAfterFirstLine(entry);
156
+ queueLine(pending);
114
157
  }
115
158
  } catch {
116
159
  // SIGTERM commonly closes a Bun stream with an iteration error. Cancellation
@@ -135,32 +178,83 @@ export class MonitorRegistry {
135
178
  };
136
179
  this.buffers.set(id, "");
137
180
 
138
- let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
181
+ let shell: LeasedShell;
139
182
  try {
140
- proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
183
+ shell = spawnLeasedShell(command, cwd);
141
184
  } catch (err) {
142
185
  record.status = "failed";
143
186
  record.finishedAt = Date.now();
144
187
  this.append(id, err instanceof Error ? err.message : String(err));
188
+ this.pendingMutations.add(id);
145
189
  const failed: Entry = { record, promise: Promise.resolve() };
146
190
  this.entries.set(id, failed);
147
191
  return record;
148
192
  }
149
193
 
150
- const entry = { record, proc, promise: Promise.resolve() } as Entry;
194
+ this.active.add(id);
195
+ const entry: Entry = { record, promise: Promise.resolve() };
196
+ entry.cancel = (): boolean => {
197
+ const outcome = shell.killGroup();
198
+ if (outcome === "delivered") { entry.killDelivered = true; return true; }
199
+ entry.incomplete = true;
200
+ this.append(id, outcome === "unpinned"
201
+ ? "\n[monitor process group is no longer pinned; termination cannot be confirmed]\n"
202
+ : outcome === "unsupported"
203
+ ? "\n[monitor process tree termination cannot be confirmed on Windows]\n"
204
+ : `\n[monitor process group termination attempt failed: ${String(outcome.error)}]\n`);
205
+ shell.killDirect();
206
+ return false;
207
+ };
208
+ const queue: NoticeQueue = { tails: [Promise.resolve(), Promise.resolve()], count: 0, chars: 0, overflow: false, firstLineClaimed: false };
209
+ const drains = Promise.all([this.drain(entry, shell.stdout, queue, 0), this.drain(entry, shell.stderr, queue, 1)]);
151
210
  this.entries.set(id, entry);
152
211
  entry.promise = (async () => {
153
212
  try {
154
- await Promise.all([this.drain(entry, proc.stdout), this.drain(entry, proc.stderr)]);
155
- const exitCode = await proc.exited;
156
- if (record.status === "killed") return;
157
- record.status = "exited";
213
+ const exitCode = await shell.exit;
214
+ let lingered = false;
215
+ if (!(await settlesWithin(drains, PIPE_LINGER_MS))) {
216
+ lingered = true;
217
+ // A descendant still holds stdout/stderr. Only the pinned group may be signalled; if EOF does
218
+ // not follow the delivered SIGKILL, the holder left the group and stays unresolved.
219
+ if ((entry.killDelivered || entry.cancel!()) && !(await settlesWithin(drains, PIPE_LINGER_MS))) {
220
+ entry.incomplete = true;
221
+ this.append(id, "\n[monitor pipe remains open outside the process group; termination cannot be confirmed]\n");
222
+ }
223
+ shell.dispose();
224
+ await drains;
225
+ }
226
+ await Promise.all(queue.tails);
227
+ if (queue.overflow) {
228
+ record.status = "failed";
229
+ record.exitCode = exitCode;
230
+ this.append(id, "\n[monitor output incomplete: notice queue limit exceeded]\n");
231
+ return;
232
+ }
233
+ if (exitCode === null && !entry.killDelivered && !entry.incomplete) {
234
+ entry.incomplete = true;
235
+ this.append(id, "\n[monitor process group lease ended without an exit report; termination cannot be confirmed]\n");
236
+ }
237
+ if (record.status === "killed" && !entry.incomplete) return;
238
+ if (!lingered && !entry.incomplete) {
239
+ record.status = "exited";
240
+ record.exitCode = exitCode;
241
+ return;
242
+ }
243
+ record.status = "failed";
158
244
  record.exitCode = exitCode;
245
+ if (lingered) this.append(id, "\n[monitor output incomplete: a descendant kept stdout or stderr open after the shell exited]\n");
159
246
  } catch (err) {
247
+ // Output can no longer be observed: stop the group now so the holder can be reaped.
248
+ if (!entry.killDelivered) entry.cancel!();
160
249
  if (record.status === "killed") return;
161
250
  record.status = "failed";
162
251
  this.append(id, err instanceof Error ? err.message : String(err));
163
252
  } finally {
253
+ shell.release();
254
+ await shell.reaped;
255
+ shell.dispose();
256
+ if (!entry.incomplete) this.active.delete(id);
257
+ this.pendingMutations.add(id);
164
258
  if (record.finishedAt === undefined) record.finishedAt = Date.now();
165
259
  }
166
260
  })();
@@ -182,6 +276,22 @@ export class MonitorRegistry {
182
276
  return this.list().filter(r => r.status === "running");
183
277
  }
184
278
 
279
+ /** Processes not yet settled or whose completion has not reached the engine. */
280
+ unobserved(): MonitorRecord[] {
281
+ return this.list().filter(record => this.active.has(record.id) || this.pendingMutations.has(record.id));
282
+ }
283
+
284
+ consumeMutations(ids?: string[]): boolean {
285
+ if (ids === undefined) {
286
+ const pending = this.pendingMutations.size > 0;
287
+ this.pendingMutations.clear();
288
+ return pending;
289
+ }
290
+ let pending = false;
291
+ for (const id of ids) pending = this.pendingMutations.delete(id) || pending;
292
+ return pending;
293
+ }
294
+
185
295
  /** Current buffered stdout/stderr for one monitor. */
186
296
  tail(id: string): string {
187
297
  return this.buffers.get(id) ?? "";
@@ -212,22 +322,24 @@ export class MonitorRegistry {
212
322
  for (const id of ids) {
213
323
  const entry = this.entries.get(id);
214
324
  if (!entry) continue;
325
+ if (entry.incomplete) { out.push(entry.record); continue; }
215
326
  if (entry.record.status === "running") {
216
- entry.record.status = "killed";
327
+ const killed = entry.cancel?.();
328
+ entry.record.status = killed ? "killed" : "failed";
217
329
  entry.record.finishedAt = Date.now();
218
- try {
219
- entry.proc?.kill();
220
- } catch {
221
- // Process exited concurrently; the terminal state is already correct.
222
- }
223
330
  }
224
331
  out.push(entry.record);
225
332
  }
226
333
  return out;
227
334
  }
228
335
 
336
+ /** Include unresolved process groups even if their shell has exited. */
337
+ cancellable(): MonitorRecord[] {
338
+ return this.list().filter(record => record.status === "running" || this.entries.get(record.id)?.incomplete);
339
+ }
340
+
229
341
  /** Kill every monitor still running at session teardown / Ctrl-C. */
230
342
  cancelAll(): MonitorRecord[] {
231
- return this.cancel(this.running().map(record => record.id));
343
+ return this.cancel(this.cancellable().map(record => record.id));
232
344
  }
233
345
  }