jeo-code 0.12.13 → 0.12.16
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/CHANGELOG.md +55 -1
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +358 -0
- package/src/agent/sandbox/bundle.ts +40 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +293 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +172 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +275 -35
- package/src/commands/messenger.ts +107 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +78 -10
- package/src/messenger/discord-setup.ts +216 -34
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +85 -37
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +5 -2
- package/src/skills/catalog.ts +207 -0
- package/src/util/retry.ts +12 -0
package/src/agent/loop-guards.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/agent/memory.ts
CHANGED
|
@@ -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 (
|
|
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:
|
|
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
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
/**
|
|
1147
|
-
*
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
90
|
-
const
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
181
|
+
let shell: LeasedShell;
|
|
139
182
|
try {
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
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
|
-
|
|
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.
|
|
343
|
+
return this.cancel(this.cancellable().map(record => record.id));
|
|
232
344
|
}
|
|
233
345
|
}
|