@kushalkhemka/opensec-pi-subagents 0.20.2 → 0.20.4
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 +105 -0
- package/README.md +148 -7
- package/dist/agent-manager.d.ts +119 -9
- package/dist/agent-manager.js +263 -56
- package/dist/agent-runner.d.ts +11 -0
- package/dist/agent-runner.js +96 -22
- package/dist/completion-outbox.d.ts +65 -0
- package/dist/completion-outbox.js +108 -0
- package/dist/cross-extension-rpc.js +8 -0
- package/dist/env.js +3 -2
- package/dist/exec-tree.js +11 -2
- package/dist/index.d.ts +7 -1
- package/dist/index.js +846 -301
- package/dist/invocation-config.d.ts +17 -4
- package/dist/invocation-config.js +29 -2
- package/dist/nested-tools.js +10 -4
- package/dist/schedule.d.ts +1 -1
- package/dist/schedule.js +10 -4
- package/dist/session-handoff.d.ts +102 -0
- package/dist/session-handoff.js +162 -0
- package/dist/settings.d.ts +24 -12
- package/dist/settings.js +10 -5
- package/dist/types.d.ts +24 -16
- package/dist/ui/agent-widget.d.ts +4 -0
- package/dist/ui/agent-widget.js +5 -0
- package/dist/ui/conversation-viewer.d.ts +113 -74
- package/dist/ui/conversation-viewer.js +495 -377
- package/dist/ui/fleet-list.d.ts +35 -28
- package/dist/ui/fleet-list.js +41 -25
- package/dist/ui/viewer-keys.d.ts +10 -1
- package/dist/ui/viewer-keys.js +5 -0
- package/dist/ui/viewer-transcript.d.ts +152 -0
- package/dist/ui/viewer-transcript.js +721 -0
- package/dist/workflow/collisions.d.ts +3 -1
- package/dist/workflow/collisions.js +4 -1
- package/dist/workflow/host.js +30 -7
- package/dist/workflow/journal.d.ts +16 -8
- package/dist/workflow/journal.js +12 -9
- package/dist/workflow/runtime.d.ts +7 -1
- package/dist/workflow/runtime.js +38 -8
- package/dist/workflow/task.d.ts +11 -1
- package/dist/workflow/task.js +13 -1
- package/dist/workflow/tool-description.d.ts +9 -1
- package/dist/workflow/tool-description.js +21 -1
- package/docs/rpc.md +10 -10
- package/docs/workflows.md +4 -4
- package/examples/agent-tool-description.md +1 -3
- package/package.json +16 -11
- package/src/agent-manager.ts +288 -55
- package/src/agent-runner.ts +100 -21
- package/src/completion-outbox.ts +121 -0
- package/src/cross-extension-rpc.ts +8 -0
- package/src/env.ts +3 -2
- package/src/exec-tree.ts +11 -2
- package/src/index.ts +830 -292
- package/src/invocation-config.ts +44 -4
- package/src/nested-tools.ts +10 -4
- package/src/schedule.ts +10 -4
- package/src/session-handoff.ts +188 -0
- package/src/settings.ts +32 -16
- package/src/types.ts +24 -17
- package/src/ui/agent-widget.ts +8 -0
- package/src/ui/conversation-viewer.ts +487 -373
- package/src/ui/fleet-list.ts +39 -24
- package/src/ui/viewer-keys.ts +13 -1
- package/src/ui/viewer-transcript.ts +779 -0
- package/src/workflow/collisions.ts +4 -1
- package/src/workflow/host.ts +30 -7
- package/src/workflow/journal.ts +18 -9
- package/src/workflow/runtime.ts +50 -8
- package/src/workflow/task.ts +14 -0
- package/src/workflow/tool-description.ts +27 -1
package/dist/agent-manager.js
CHANGED
|
@@ -17,11 +17,30 @@ import { randomUUID } from "node:crypto";
|
|
|
17
17
|
import { existsSync, statSync } from "node:fs";
|
|
18
18
|
import { isAbsolute } from "node:path";
|
|
19
19
|
import { resumeAgent, runAgent } from "./agent-runner.js";
|
|
20
|
+
import { getAgentConfig } from "./agent-types.js";
|
|
20
21
|
import { assignHandle, handleBase } from "./mention.js";
|
|
21
22
|
import { describeModel } from "./model-resolver.js";
|
|
22
23
|
import { sessionFileProblem } from "./session-file.js";
|
|
24
|
+
import { sessionFileOf, snapshotContext } from "./session-handoff.js";
|
|
23
25
|
import { addUsage } from "./usage.js";
|
|
24
26
|
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, restoreWorktree } from "./worktree.js";
|
|
27
|
+
/**
|
|
28
|
+
* This module's {@link ManagerServices}, for an instance adopting a manager
|
|
29
|
+
* built by another copy. Getters, so each import is read only when used.
|
|
30
|
+
*/
|
|
31
|
+
export function managerServices() {
|
|
32
|
+
return {
|
|
33
|
+
get runAgent() { return runAgent; },
|
|
34
|
+
get resumeAgent() { return resumeAgent; },
|
|
35
|
+
get createWorktree() { return createWorktree; },
|
|
36
|
+
get cleanupWorktree() { return cleanupWorktree; },
|
|
37
|
+
get restoreWorktree() { return restoreWorktree; },
|
|
38
|
+
get isWorktreeIsolationEnabled() { return isWorktreeIsolationEnabled; },
|
|
39
|
+
get pruneWorktrees() { return pruneWorktrees; },
|
|
40
|
+
get sessionFileProblem() { return sessionFileProblem; },
|
|
41
|
+
get getAgentConfig() { return getAgentConfig; },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
25
44
|
/**
|
|
26
45
|
* Default max concurrent background agents.
|
|
27
46
|
*
|
|
@@ -50,6 +69,11 @@ const DEFAULT_MAX_CONCURRENT_FOREGROUND = 0;
|
|
|
50
69
|
* far above the handful anyone keeps in their head.
|
|
51
70
|
*/
|
|
52
71
|
const MAX_TOMBSTONES = 100;
|
|
72
|
+
/**
|
|
73
|
+
* The same bound for owned children — nested agents and workflow children —
|
|
74
|
+
* counted apart, so a large workflow cannot push the session's own agents out.
|
|
75
|
+
*/
|
|
76
|
+
const MAX_OWNED_TOMBSTONES = 200;
|
|
53
77
|
/**
|
|
54
78
|
* Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
|
|
55
79
|
* (parent cwd). Anything else must be an absolute path to an existing
|
|
@@ -260,6 +284,7 @@ export function resumeStateOf(record) {
|
|
|
260
284
|
maxSubagentDepth: record.maxSubagentDepth,
|
|
261
285
|
rootSessionId: record.rootSessionId,
|
|
262
286
|
model: record.invocation?.modelId,
|
|
287
|
+
thinking: record.invocation?.thinking,
|
|
263
288
|
cwd: record.cwd,
|
|
264
289
|
baseCwd: record.baseCwd,
|
|
265
290
|
isolated: record.isolated,
|
|
@@ -269,16 +294,25 @@ export function resumeStateOf(record) {
|
|
|
269
294
|
worktreeResult: record.worktreeResult ? { ...record.worktreeResult } : undefined,
|
|
270
295
|
};
|
|
271
296
|
}
|
|
297
|
+
/** Session entry a top-level agent's final record goes into (with its resume state). */
|
|
298
|
+
export const RECORD_ENTRY_TYPE = "subagents:record";
|
|
272
299
|
/**
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
300
|
+
* Session entry an owned child's resume state goes into: a nested agent's or a
|
|
301
|
+
* workflow's. Kept apart from {@link RECORD_ENTRY_TYPE}, which other extensions
|
|
302
|
+
* read as the session's own agents, while an owned child reports only through
|
|
303
|
+
* its owner. Carries the resume state alone, so it stays small.
|
|
304
|
+
*/
|
|
305
|
+
export const RESUME_ENTRY_TYPE = "subagents:resume";
|
|
306
|
+
/**
|
|
307
|
+
* The resume states a session's `subagents:record` and `subagents:resume`
|
|
308
|
+
* entries carry, oldest first. Entries written before they carried one, or
|
|
309
|
+
* malformed ones, are skipped: there is nothing to reopen them from.
|
|
276
310
|
*/
|
|
277
311
|
export function resumeStatesFromEntries(entries) {
|
|
278
312
|
const out = [];
|
|
279
313
|
for (const entry of entries) {
|
|
280
314
|
const e = entry;
|
|
281
|
-
if (e?.type !== "custom" || e.customType !==
|
|
315
|
+
if (e?.type !== "custom" || (e.customType !== RECORD_ENTRY_TYPE && e.customType !== RESUME_ENTRY_TYPE))
|
|
282
316
|
continue;
|
|
283
317
|
const state = e.data?.resume;
|
|
284
318
|
if (typeof state?.id !== "string" || typeof state.type !== "string"
|
|
@@ -288,6 +322,8 @@ export function resumeStatesFromEntries(entries) {
|
|
|
288
322
|
}
|
|
289
323
|
return out;
|
|
290
324
|
}
|
|
325
|
+
/** The manager's own module functions, read lazily (see {@link managerServices}). */
|
|
326
|
+
const DEFAULT_SERVICES = managerServices();
|
|
291
327
|
export class AgentManager {
|
|
292
328
|
agents = new Map();
|
|
293
329
|
cleanupInterval;
|
|
@@ -347,6 +383,20 @@ export class AgentManager {
|
|
|
347
383
|
/** Pending releases, by record. */
|
|
348
384
|
releaseTimers = new Map();
|
|
349
385
|
sessionReleaseMs = DEFAULT_SESSION_RELEASE_MS;
|
|
386
|
+
/** Set by an instance that adopted this manager; see {@link ManagerServices}. */
|
|
387
|
+
services;
|
|
388
|
+
/**
|
|
389
|
+
* The session whose agents the top-level surfaces show (`listAgents`,
|
|
390
|
+
* `resolveMention`, `hasRunning`, `waitForAll`). `null`: every agent — the
|
|
391
|
+
* default, and the only mode before a session has started.
|
|
392
|
+
*/
|
|
393
|
+
visibleOwner = null;
|
|
394
|
+
/**
|
|
395
|
+
* Arguments of spawns that have not reached `runAgent` yet (queued, or still
|
|
396
|
+
* creating their worktree), so a session switch can swap their context for a
|
|
397
|
+
* frozen copy before Pi invalidates it.
|
|
398
|
+
*/
|
|
399
|
+
pendingArgs = new Map();
|
|
350
400
|
constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact, onUsage) {
|
|
351
401
|
this.onComplete = onComplete;
|
|
352
402
|
this.onStart = onStart;
|
|
@@ -357,6 +407,42 @@ export class AgentManager {
|
|
|
357
407
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
358
408
|
this.cleanupInterval.unref();
|
|
359
409
|
}
|
|
410
|
+
/** Replace what the extension observes of this manager's agents (an adopting instance, or parking). */
|
|
411
|
+
setObservers(observers) {
|
|
412
|
+
this.onComplete = observers.onComplete;
|
|
413
|
+
this.onStart = observers.onStart;
|
|
414
|
+
this.onCompact = observers.onCompact;
|
|
415
|
+
this.onUsage = observers.onUsage;
|
|
416
|
+
}
|
|
417
|
+
/** Run new work with these module functions; see {@link ManagerServices}. */
|
|
418
|
+
useServices(services) {
|
|
419
|
+
this.services = services;
|
|
420
|
+
}
|
|
421
|
+
svc() {
|
|
422
|
+
return this.services ?? DEFAULT_SERVICES;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Show only the agents of this session (its file; undefined for one in
|
|
426
|
+
* memory) on the top-level surfaces, or every agent with `null`. Agents of
|
|
427
|
+
* other sessions keep running and are reachable by id internally — their
|
|
428
|
+
* nested tools, the workflow host — just not listed or addressable here.
|
|
429
|
+
*/
|
|
430
|
+
setVisibleOwner(owner) {
|
|
431
|
+
this.visibleOwner = owner;
|
|
432
|
+
}
|
|
433
|
+
/** Whether the record belongs to the session the surfaces show. */
|
|
434
|
+
isVisible(record) {
|
|
435
|
+
return this.visibleOwner === null || record.ownerSession === this.visibleOwner;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Before a session switch: give every spawn that has not started its run yet
|
|
439
|
+
* a frozen copy of its context, since Pi invalidates the live one (every read
|
|
440
|
+
* throws) and the spawn reads it when it starts.
|
|
441
|
+
*/
|
|
442
|
+
detachPendingContexts() {
|
|
443
|
+
for (const args of this.pendingArgs.values())
|
|
444
|
+
args.ctx = snapshotContext(args.ctx);
|
|
445
|
+
}
|
|
360
446
|
/** Update the max concurrent background agents limit. */
|
|
361
447
|
setMaxConcurrent(n) {
|
|
362
448
|
this.maxConcurrent = Math.max(1, n);
|
|
@@ -478,6 +564,9 @@ export class AgentManager {
|
|
|
478
564
|
if (run.settled)
|
|
479
565
|
return;
|
|
480
566
|
run.settled = true;
|
|
567
|
+
// Nobody waits on a settled run; a later foreground resume must not look
|
|
568
|
+
// like a spawn `sendToBackground` could hand off.
|
|
569
|
+
run.record.blocking = false;
|
|
481
570
|
if (run.graceTimer)
|
|
482
571
|
clearTimeout(run.graceTimer);
|
|
483
572
|
if (this.runs.get(run.id) === run)
|
|
@@ -650,6 +739,11 @@ export class AgentManager {
|
|
|
650
739
|
maxSubagentDepth: options.maxSubagentDepth,
|
|
651
740
|
rootSessionId: options.rootSessionId,
|
|
652
741
|
isolated: options.isolated,
|
|
742
|
+
// An owned child belongs where its root does: its ctx is its parent
|
|
743
|
+
// agent's own session, not the user's.
|
|
744
|
+
ownerSession: options.parentAgentId !== undefined
|
|
745
|
+
? this.agents.get(options.parentAgentId)?.ownerSession
|
|
746
|
+
: sessionFileOf(ctx),
|
|
653
747
|
};
|
|
654
748
|
this.agents.set(id, record);
|
|
655
749
|
// After the insert, so `takenHandles()` already counts this record's own
|
|
@@ -659,6 +753,7 @@ export class AgentManager {
|
|
|
659
753
|
record.alias = assignHandle(handleBase(options.name), this.takenHandles());
|
|
660
754
|
}
|
|
661
755
|
const args = { pi, ctx, type, prompt, options };
|
|
756
|
+
this.pendingArgs.set(id, args);
|
|
662
757
|
const pool = this.poolFor(record);
|
|
663
758
|
if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
|
|
664
759
|
// Queue it — started when a running agent in the same pool completes.
|
|
@@ -693,23 +788,40 @@ export class AgentManager {
|
|
|
693
788
|
* an aborted signal, so a `spawnAndWait` on it would wait forever — pi has no
|
|
694
789
|
* tool-execution timeout to bail it out.
|
|
695
790
|
*
|
|
696
|
-
* The listener is
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
* `abortAll`/`dispose` paths could not offer anyway.
|
|
791
|
+
* The listener is removable (`record.detachParentAbort`): `startAgent`
|
|
792
|
+
* replaces it with its own, and `sendToBackground` must be able to take it
|
|
793
|
+
* off, or the caller that armed it could stop a run it no longer owns.
|
|
700
794
|
*/
|
|
701
795
|
armQueuedAbort(id, signal) {
|
|
702
796
|
if (signal === undefined)
|
|
703
797
|
return true;
|
|
798
|
+
const record = this.agents.get(id);
|
|
704
799
|
if (signal.aborted) {
|
|
705
|
-
const record = this.agents.get(id);
|
|
706
800
|
if (record)
|
|
707
801
|
this.stopQueued(record);
|
|
708
802
|
return false;
|
|
709
803
|
}
|
|
710
|
-
|
|
804
|
+
if (record)
|
|
805
|
+
this.armParentAbort(record, signal);
|
|
711
806
|
return true;
|
|
712
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Stop `record` when its caller's `signal` aborts, replacing any listener an
|
|
810
|
+
* earlier stage armed — at most one is live per record, so one call to
|
|
811
|
+
* `detachParentAbort` cuts the run loose from its caller.
|
|
812
|
+
*/
|
|
813
|
+
armParentAbort(record, signal) {
|
|
814
|
+
this.detachParentAbort(record);
|
|
815
|
+
if (signal === undefined)
|
|
816
|
+
return;
|
|
817
|
+
const onParentAbort = () => this.abort(record.id);
|
|
818
|
+
signal.addEventListener("abort", onParentAbort, { once: true });
|
|
819
|
+
record.detachParentAbort = () => signal.removeEventListener("abort", onParentAbort);
|
|
820
|
+
}
|
|
821
|
+
detachParentAbort(record) {
|
|
822
|
+
record.detachParentAbort?.();
|
|
823
|
+
record.detachParentAbort = undefined;
|
|
824
|
+
}
|
|
713
825
|
/**
|
|
714
826
|
* Kick off an agent's startup and register it under `startups`. The returned
|
|
715
827
|
* promise never rejects — the failure is delivered through `awaitStartup`,
|
|
@@ -724,8 +836,9 @@ export class AgentManager {
|
|
|
724
836
|
* handle goes back.
|
|
725
837
|
*/
|
|
726
838
|
launch(id, record, args, queuedPool) {
|
|
727
|
-
const startup = this.startAgent(id, record, args).then(() => { this.startups.delete(id); }, (err) => {
|
|
839
|
+
const startup = this.startAgent(id, record, args).then(() => { this.startups.delete(id); this.dropPendingArgs(id, args); }, (err) => {
|
|
728
840
|
this.startups.delete(id);
|
|
841
|
+
this.dropPendingArgs(id, args);
|
|
729
842
|
if (queuedPool !== undefined) {
|
|
730
843
|
// Mirrors endRun: an inline caller gets this failure as a throw
|
|
731
844
|
// out of spawnAndWait, so an unconsumed record would ALSO nudge the
|
|
@@ -751,6 +864,10 @@ export class AgentManager {
|
|
|
751
864
|
// (drainQueue) that swallowed promise.
|
|
752
865
|
return startup.catch(() => { });
|
|
753
866
|
}
|
|
867
|
+
dropPendingArgs(id, args) {
|
|
868
|
+
if (this.pendingArgs.get(id) === args)
|
|
869
|
+
this.pendingArgs.delete(id);
|
|
870
|
+
}
|
|
754
871
|
/**
|
|
755
872
|
* Resolves once the agent is actually running, and rejects with the startup
|
|
756
873
|
* failure (strict worktree isolation) that `spawn()` used to throw before the
|
|
@@ -764,7 +881,13 @@ export class AgentManager {
|
|
|
764
881
|
return this.startups.get(id) ?? Promise.resolve();
|
|
765
882
|
}
|
|
766
883
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
767
|
-
async startAgent(id, record,
|
|
884
|
+
async startAgent(id, record, args) {
|
|
885
|
+
// `args.ctx` is read again where the run starts, not destructured here: a
|
|
886
|
+
// session switch during the worktree copy swaps it for a frozen copy.
|
|
887
|
+
const { pi, type, prompt, options } = args;
|
|
888
|
+
const ctx = args.ctx;
|
|
889
|
+
// The functions this run starts and settles with, fixed for its lifetime.
|
|
890
|
+
const svc = this.svc();
|
|
768
891
|
// Re-validate a caller-supplied cwd: queued spawns can start minutes after
|
|
769
892
|
// spawn()'s check, and the directory may be gone by then (TOCTOU). Same
|
|
770
893
|
// curated errors; drainQueue parks a throw on the record as an error.
|
|
@@ -772,7 +895,8 @@ export class AgentManager {
|
|
|
772
895
|
// Single resolution point for the caller-supplied cwd — the worktree base
|
|
773
896
|
// repo and both cleanup calls below MUST agree on this value forever.
|
|
774
897
|
const customCwd = options.cwd ?? undefined; // null (RPC "unset") → undefined
|
|
775
|
-
const
|
|
898
|
+
const sessionCwd = ctx.cwd;
|
|
899
|
+
const baseCwd = customCwd ?? sessionCwd;
|
|
776
900
|
// Take the running state — and with it the concurrency slot — BEFORE the
|
|
777
901
|
// first await. Creating a worktree is an awaited git call, and drainQueue
|
|
778
902
|
// reads the pool counters synchronously in a loop: incrementing after the
|
|
@@ -809,7 +933,7 @@ export class AgentManager {
|
|
|
809
933
|
this.releaseRunSlot(run);
|
|
810
934
|
this.drainQueue();
|
|
811
935
|
try {
|
|
812
|
-
record.worktreeResult = await cleanupWorktree(pi, baseCwd, wt, options.description);
|
|
936
|
+
record.worktreeResult = await svc.cleanupWorktree(pi, baseCwd, wt, options.description);
|
|
813
937
|
}
|
|
814
938
|
catch { /* best effort */ }
|
|
815
939
|
this.endRun(run);
|
|
@@ -820,17 +944,26 @@ export class AgentManager {
|
|
|
820
944
|
// The project switch is enforced here as well as at the tool boundary
|
|
821
945
|
// because cross-extension RPC forwards its options unvalidated — a schema
|
|
822
946
|
// that omits the field can't stop a caller that never saw the schema.
|
|
947
|
+
// The agent file's `isolation:` is resolved here too, not by each caller:
|
|
948
|
+
// mentions pass none, and RPC forwards only its caller's value. Workflow
|
|
949
|
+
// children are the exception, as in Claude Code, whose workflow agent()
|
|
950
|
+
// reads only the script's option and ignores the definition's. So is an
|
|
951
|
+
// agent reopened from its session file: it continues where it ran, and a
|
|
952
|
+
// worktree it ran in comes back through `restoreWorktree` below.
|
|
953
|
+
const isolation = options.workflowId !== undefined || options.resumeSessionFile !== undefined
|
|
954
|
+
? options.isolation
|
|
955
|
+
: svc.getAgentConfig(type)?.isolation ?? options.isolation;
|
|
823
956
|
let worktreeCwd;
|
|
824
957
|
// What an earlier run of a reopened agent left on its branch. A resumed
|
|
825
958
|
// run that changes nothing keeps reporting that, not "no changes".
|
|
826
959
|
const priorWorktreeResult = options.restoreWorktree?.prior;
|
|
827
960
|
if (options.restoreWorktree) {
|
|
828
|
-
if (!isWorktreeIsolationEnabled()) {
|
|
961
|
+
if (!svc.isWorktreeIsolationEnabled()) {
|
|
829
962
|
this.discardRun(run);
|
|
830
963
|
throw new Error(`Cannot resume agent ${id}: it ran in a git worktree, and worktree isolation is turned off in this project.`);
|
|
831
964
|
}
|
|
832
965
|
const wt = { ...options.restoreWorktree.worktree };
|
|
833
|
-
const error = await restoreWorktree(pi, baseCwd, wt, priorWorktreeResult);
|
|
966
|
+
const error = await svc.restoreWorktree(pi, baseCwd, wt, priorWorktreeResult);
|
|
834
967
|
if (error) {
|
|
835
968
|
this.discardRun(run);
|
|
836
969
|
throw new Error(`Cannot resume agent ${id} in its git worktree: ${error}. Nothing was run.`);
|
|
@@ -844,8 +977,8 @@ export class AgentManager {
|
|
|
844
977
|
return;
|
|
845
978
|
}
|
|
846
979
|
}
|
|
847
|
-
else if (
|
|
848
|
-
const wt = await createWorktree(pi, baseCwd, id);
|
|
980
|
+
else if (isolation === "worktree" && svc.isWorktreeIsolationEnabled()) {
|
|
981
|
+
const wt = await svc.createWorktree(pi, baseCwd, id);
|
|
849
982
|
if (!wt) {
|
|
850
983
|
this.discardRun(run);
|
|
851
984
|
throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
@@ -860,6 +993,10 @@ export class AgentManager {
|
|
|
860
993
|
// subdirectory, silently dropping extensions/skills.
|
|
861
994
|
worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
|
|
862
995
|
this.worktreeRepos.add(baseCwd);
|
|
996
|
+
// The widget's "worktree" tag reads this; only callers that resolve the
|
|
997
|
+
// agent file themselves seed it.
|
|
998
|
+
record.invocation ??= {};
|
|
999
|
+
record.invocation.isolation = "worktree";
|
|
863
1000
|
// No longer "running" means a stop landed while the copy was being made
|
|
864
1001
|
// (abort(), abortAll()) — a window that did not exist when creation was
|
|
865
1002
|
// synchronous. The record is already terminal, so launching the run would
|
|
@@ -875,22 +1012,16 @@ export class AgentManager {
|
|
|
875
1012
|
this.onStart?.(record);
|
|
876
1013
|
}
|
|
877
1014
|
catch { /* a start observer must not fail the run */ }
|
|
878
|
-
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
889
|
-
detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
893
|
-
const promise = runAgent(ctx, type, prompt, {
|
|
1015
|
+
// Wire parent abort signal to stop the subagent when the parent is interrupted.
|
|
1016
|
+
// A queued spawn can start minutes after the caller handed us its signal,
|
|
1017
|
+
// by which time it may already be aborted — and `addEventListener` would
|
|
1018
|
+
// never fire, leaving a child the parent can no longer reach.
|
|
1019
|
+
if (options.signal?.aborted)
|
|
1020
|
+
this.abort(id);
|
|
1021
|
+
else
|
|
1022
|
+
this.armParentAbort(record, options.signal);
|
|
1023
|
+
const detach = () => this.detachParentAbort(record);
|
|
1024
|
+
const promise = svc.runAgent(args.ctx, type, prompt, {
|
|
894
1025
|
pi,
|
|
895
1026
|
agentId: id,
|
|
896
1027
|
model: options.model,
|
|
@@ -911,7 +1042,7 @@ export class AgentManager {
|
|
|
911
1042
|
// Set iff a worktree was created (see above) — names the directory the
|
|
912
1043
|
// copy came from, so the prompt can tell the agent not to work there.
|
|
913
1044
|
worktreeBase: worktreeCwd ? baseCwd : undefined,
|
|
914
|
-
configCwd: options.configCwd ?? (customCwd !== undefined ?
|
|
1045
|
+
configCwd: options.configCwd ?? (customCwd !== undefined ? sessionCwd : undefined),
|
|
915
1046
|
signal: record.abortController.signal,
|
|
916
1047
|
onToolActivity: (activity) => {
|
|
917
1048
|
if (activity.type === "end")
|
|
@@ -982,7 +1113,12 @@ export class AgentManager {
|
|
|
982
1113
|
}
|
|
983
1114
|
record.pendingSteers = undefined;
|
|
984
1115
|
}
|
|
985
|
-
|
|
1116
|
+
// A caller's wiring must not fail the run (it may read a ctx a session
|
|
1117
|
+
// switch has since invalidated).
|
|
1118
|
+
try {
|
|
1119
|
+
options.onSessionCreated?.(session);
|
|
1120
|
+
}
|
|
1121
|
+
catch { /* ignore caller wiring errors */ }
|
|
986
1122
|
},
|
|
987
1123
|
})
|
|
988
1124
|
.then(async ({ responseText, session, aborted, steered, failure, structuredJson, structuredRetried, maxTurns }) => {
|
|
@@ -1029,7 +1165,7 @@ export class AgentManager {
|
|
|
1029
1165
|
// The hook runs inside cleanup: after the child's changes are
|
|
1030
1166
|
// committed and while its tree still exists; what it changes is
|
|
1031
1167
|
// discarded, so a gate's output never lands on the branch.
|
|
1032
|
-
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description, options.onBeforeWorktreeCleanup);
|
|
1168
|
+
const wtResult = await svc.cleanupWorktree(pi, baseCwd, record.worktree, options.description, options.onBeforeWorktreeCleanup);
|
|
1033
1169
|
record.worktreeResult = wtResult.hasChanges || !priorWorktreeResult ? wtResult : priorWorktreeResult;
|
|
1034
1170
|
// Appended to the prose only. A structured child's caller parses
|
|
1035
1171
|
// `structuredJson`, which stays untouched — but `result` is also
|
|
@@ -1065,7 +1201,7 @@ export class AgentManager {
|
|
|
1065
1201
|
// Best-effort worktree cleanup on error
|
|
1066
1202
|
if (record.worktree) {
|
|
1067
1203
|
try {
|
|
1068
|
-
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1204
|
+
const wtResult = await svc.cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1069
1205
|
record.worktreeResult = wtResult.hasChanges || !priorWorktreeResult ? wtResult : priorWorktreeResult;
|
|
1070
1206
|
// A failed run can still have changed files: say where they went,
|
|
1071
1207
|
// on the error every failure surface shows.
|
|
@@ -1094,7 +1230,7 @@ export class AgentManager {
|
|
|
1094
1230
|
}
|
|
1095
1231
|
if (record.worktree) {
|
|
1096
1232
|
try {
|
|
1097
|
-
const wtResult = await cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1233
|
+
const wtResult = await svc.cleanupWorktree(pi, baseCwd, record.worktree, options.description);
|
|
1098
1234
|
record.worktreeResult = wtResult.hasChanges || !priorWorktreeResult ? wtResult : priorWorktreeResult;
|
|
1099
1235
|
const note = worktreeNote(record.worktreeResult, baseCwd, customCwd !== undefined);
|
|
1100
1236
|
if (note)
|
|
@@ -1227,6 +1363,50 @@ export class AgentManager {
|
|
|
1227
1363
|
}
|
|
1228
1364
|
return { id, record };
|
|
1229
1365
|
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Hand a live run to the background without restarting it. The session,
|
|
1368
|
+
* promise, in-flight turn, transcript and worktree all continue under the
|
|
1369
|
+
* same id; what changes is who answers for it. The caller's parent-abort
|
|
1370
|
+
* listener comes off (its Esc no longer reaches the run — `abort()` and
|
|
1371
|
+
* `/agents` still do), the slot moves to the background pool, and the run
|
|
1372
|
+
* then settles through the ordinary background path: `endRun` releases the
|
|
1373
|
+
* run's live pool, and the settle reads the live `isBackground`, so the
|
|
1374
|
+
* result is delivered as a background completion.
|
|
1375
|
+
*
|
|
1376
|
+
* Releasing the caller is the caller's job — the `Agent` tool races its own
|
|
1377
|
+
* wake-up against `spawnAndWait`. This only refuses what it can see: a run
|
|
1378
|
+
* nobody is blocking on (a background or detached spawn, one already handed
|
|
1379
|
+
* off, or a foreground resume — `blocking` is cleared when the spawned run
|
|
1380
|
+
* settles), one that reports through something other than the session (a
|
|
1381
|
+
* nested child, a workflow's agent), and one that is stopped or queued.
|
|
1382
|
+
*
|
|
1383
|
+
* The background slot is taken even when the pool is full: suspending a
|
|
1384
|
+
* live run is not an option, so it runs over the limit and queued
|
|
1385
|
+
* background work waits for the count to drop, as with `bypassQueue`.
|
|
1386
|
+
*/
|
|
1387
|
+
sendToBackground(id) {
|
|
1388
|
+
const record = this.agents.get(id);
|
|
1389
|
+
const run = this.runs.get(id);
|
|
1390
|
+
if (!record?.blocking || !isTopLevelAgent(record) || record.status !== "running" || !record.promise)
|
|
1391
|
+
return false;
|
|
1392
|
+
if (!run || run.settled || run.record !== record)
|
|
1393
|
+
return false;
|
|
1394
|
+
this.detachParentAbort(record);
|
|
1395
|
+
if (!run.slotReleased) {
|
|
1396
|
+
if (run.pool === "foreground")
|
|
1397
|
+
this.runningForeground--;
|
|
1398
|
+
else if (run.pool === "background")
|
|
1399
|
+
this.runningBackground--;
|
|
1400
|
+
this.runningBackground++;
|
|
1401
|
+
}
|
|
1402
|
+
run.pool = "background";
|
|
1403
|
+
record.blocking = false;
|
|
1404
|
+
record.isBackground = true;
|
|
1405
|
+
record.resultConsumed = false;
|
|
1406
|
+
// A freed foreground slot may have unblocked a queued blocking spawn.
|
|
1407
|
+
this.drainQueue();
|
|
1408
|
+
return true;
|
|
1409
|
+
}
|
|
1230
1410
|
/**
|
|
1231
1411
|
* Resume an existing agent session with a new prompt.
|
|
1232
1412
|
*/
|
|
@@ -1375,7 +1555,7 @@ export class AgentManager {
|
|
|
1375
1555
|
this.abort(id);
|
|
1376
1556
|
else
|
|
1377
1557
|
signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
1378
|
-
const { text, failure, aborted, steered } = abortController.signal.aborted ? { text: "" } : await resumeAgent(session, prompt, {
|
|
1558
|
+
const { text, failure, aborted, steered } = abortController.signal.aborted ? { text: "" } : await this.svc().resumeAgent(session, prompt, {
|
|
1379
1559
|
maxTurns: options?.maxTurns ?? record.maxTurns,
|
|
1380
1560
|
onTurnEnd: options?.onTurnEnd,
|
|
1381
1561
|
onToolActivity: (activity) => {
|
|
@@ -1445,7 +1625,7 @@ export class AgentManager {
|
|
|
1445
1625
|
if (!pi || !ctx) {
|
|
1446
1626
|
throw new Error(`Agent ${entry.id} has finished and is no longer in memory, and it cannot be reopened from here.`);
|
|
1447
1627
|
}
|
|
1448
|
-
const problem = sessionFileProblem(entry.sessionFile);
|
|
1628
|
+
const problem = this.svc().sessionFileProblem(entry.sessionFile);
|
|
1449
1629
|
if (problem) {
|
|
1450
1630
|
// Gone for good: a row that can only ever fail is worse than none.
|
|
1451
1631
|
if (!existsSync(entry.sessionFile))
|
|
@@ -1481,6 +1661,7 @@ export class AgentManager {
|
|
|
1481
1661
|
maxSubagentDepth: entry.maxSubagentDepth,
|
|
1482
1662
|
rootSessionId: entry.rootSessionId,
|
|
1483
1663
|
invocation: {
|
|
1664
|
+
...(entry.thinking ? { thinking: entry.thinking } : {}),
|
|
1484
1665
|
...(entry.isolated ? { isolated: true } : {}),
|
|
1485
1666
|
...(entry.worktree ? { isolation: "worktree" } : {}),
|
|
1486
1667
|
},
|
|
@@ -1525,13 +1706,13 @@ export class AgentManager {
|
|
|
1525
1706
|
async prepareResume(record, options) {
|
|
1526
1707
|
const worktree = record.worktree;
|
|
1527
1708
|
if (worktree?.path && !existsSync(worktree.path)) {
|
|
1528
|
-
if (!isWorktreeIsolationEnabled()) {
|
|
1709
|
+
if (!this.svc().isWorktreeIsolationEnabled()) {
|
|
1529
1710
|
throw new Error(`Cannot resume agent ${record.id}: it ran in a git worktree, and worktree isolation is turned off in this project.`);
|
|
1530
1711
|
}
|
|
1531
1712
|
const pi = options?.pi ?? this.pi;
|
|
1532
1713
|
const repo = record.baseCwd;
|
|
1533
1714
|
const error = pi && repo
|
|
1534
|
-
? await restoreWorktree(pi, repo, worktree, record.worktreeResult)
|
|
1715
|
+
? await this.svc().restoreWorktree(pi, repo, worktree, record.worktreeResult)
|
|
1535
1716
|
: "the repository it ran in is unknown";
|
|
1536
1717
|
if (error) {
|
|
1537
1718
|
throw new Error(`Cannot resume agent ${record.id} in its git worktree: ${error}. Nothing was run.`);
|
|
@@ -1553,7 +1734,7 @@ export class AgentManager {
|
|
|
1553
1734
|
return;
|
|
1554
1735
|
const prior = record.worktreeResult;
|
|
1555
1736
|
try {
|
|
1556
|
-
const wtResult = await cleanupWorktree(pi, repo, record.worktree, record.description);
|
|
1737
|
+
const wtResult = await this.svc().cleanupWorktree(pi, repo, record.worktree, record.description);
|
|
1557
1738
|
record.worktreeResult = wtResult.hasChanges || !prior ? wtResult : prior;
|
|
1558
1739
|
const note = worktreeNote(record.worktreeResult, repo, record.cwd !== undefined);
|
|
1559
1740
|
if (!note)
|
|
@@ -1629,7 +1810,7 @@ export class AgentManager {
|
|
|
1629
1810
|
await windDown();
|
|
1630
1811
|
this.endRun(run);
|
|
1631
1812
|
};
|
|
1632
|
-
const promise = resumeAgent(record.session, prompt, {
|
|
1813
|
+
const promise = this.svc().resumeAgent(record.session, prompt, {
|
|
1633
1814
|
maxTurns: options.maxTurns ?? record.maxTurns,
|
|
1634
1815
|
onTurnEnd: options.onTurnEnd,
|
|
1635
1816
|
onToolActivity: (activity) => {
|
|
@@ -1728,7 +1909,7 @@ export class AgentManager {
|
|
|
1728
1909
|
const wanted = name.toLowerCase();
|
|
1729
1910
|
let fallback;
|
|
1730
1911
|
for (const record of this.agents.values()) {
|
|
1731
|
-
if (record.parentAgentId !== undefined)
|
|
1912
|
+
if (record.parentAgentId !== undefined || !this.isVisible(record))
|
|
1732
1913
|
continue;
|
|
1733
1914
|
// Handle and alias share one namespace, so at most one agent answers a
|
|
1734
1915
|
// name and it makes no difference which of the two matched.
|
|
@@ -1742,7 +1923,7 @@ export class AgentManager {
|
|
|
1742
1923
|
if (fallback)
|
|
1743
1924
|
return { kind: "live", record: fallback };
|
|
1744
1925
|
const byId = this.agents.get(name);
|
|
1745
|
-
if (byId?.parentAgentId === undefined && byId !== undefined)
|
|
1926
|
+
if (byId?.parentAgentId === undefined && byId !== undefined && this.isVisible(byId))
|
|
1746
1927
|
return { kind: "live", record: byId };
|
|
1747
1928
|
// Only once nothing live answers: a tombstone is a conversation to reopen,
|
|
1748
1929
|
// and reopening one while its record still exists would fork the session.
|
|
@@ -1780,7 +1961,12 @@ export class AgentManager {
|
|
|
1780
1961
|
listTombstones() {
|
|
1781
1962
|
return [...this.tombstones.values()].filter(isTopLevelTombstone).sort((a, b) => b.completedAt - a.completedAt);
|
|
1782
1963
|
}
|
|
1964
|
+
/** The agents of the session the surfaces show (see {@link setVisibleOwner}), newest first. */
|
|
1783
1965
|
listAgents() {
|
|
1966
|
+
return this.listAllAgents().filter(record => this.isVisible(record));
|
|
1967
|
+
}
|
|
1968
|
+
/** Every agent this manager holds, whichever session it belongs to, newest first. */
|
|
1969
|
+
listAllAgents() {
|
|
1784
1970
|
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
1785
1971
|
}
|
|
1786
1972
|
abort(id) {
|
|
@@ -1810,6 +1996,7 @@ export class AgentManager {
|
|
|
1810
1996
|
* off it. A blocking caller learns of the stop from its own tool result.
|
|
1811
1997
|
*/
|
|
1812
1998
|
stopQueued(record) {
|
|
1999
|
+
this.pendingArgs.delete(record.id);
|
|
1813
2000
|
record.status = "stopped";
|
|
1814
2001
|
record.completedAt = Date.now();
|
|
1815
2002
|
if (record.isBackground === false)
|
|
@@ -1846,11 +2033,19 @@ export class AgentManager {
|
|
|
1846
2033
|
}
|
|
1847
2034
|
keepTombstone(state) {
|
|
1848
2035
|
this.tombstones.set(state.id, state);
|
|
1849
|
-
// Bound the memory a long session can accumulate
|
|
1850
|
-
// agent someone still wants to reach is the
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
2036
|
+
// Bound the memory a long session can accumulate, per kind (top-level or
|
|
2037
|
+
// owned). Oldest first, since the agent someone still wants to reach is the
|
|
2038
|
+
// one they used most recently.
|
|
2039
|
+
const topLevel = isTopLevelAgent(state);
|
|
2040
|
+
const sameKind = [...this.tombstones.values()].filter(entry => isTopLevelAgent(entry) === topLevel);
|
|
2041
|
+
let excess = sameKind.length - (topLevel ? MAX_TOMBSTONES : MAX_OWNED_TOMBSTONES);
|
|
2042
|
+
if (excess <= 0)
|
|
2043
|
+
return;
|
|
2044
|
+
sameKind.sort((a, b) => a.completedAt - b.completedAt);
|
|
2045
|
+
for (const entry of sameKind) {
|
|
2046
|
+
if (excess-- <= 0)
|
|
2047
|
+
break;
|
|
2048
|
+
this.tombstones.delete(entry.id);
|
|
1854
2049
|
}
|
|
1855
2050
|
}
|
|
1856
2051
|
/**
|
|
@@ -1891,6 +2086,8 @@ export class AgentManager {
|
|
|
1891
2086
|
for (const [id, record] of this.agents) {
|
|
1892
2087
|
if (this.isRunActive(id))
|
|
1893
2088
|
continue;
|
|
2089
|
+
// Kept too: another session's agent that finished while that session was
|
|
2090
|
+
// closed, until it is opened again and told (or the cleanup evicts it).
|
|
1894
2091
|
if (skipUnconsumed && !record.resultConsumed)
|
|
1895
2092
|
continue;
|
|
1896
2093
|
this.removeRecord(id, record);
|
|
@@ -1908,7 +2105,10 @@ export class AgentManager {
|
|
|
1908
2105
|
* whose run has not settled yet, which still holds its slot.
|
|
1909
2106
|
*/
|
|
1910
2107
|
hasRunning() {
|
|
1911
|
-
|
|
2108
|
+
for (const run of this.runs.values())
|
|
2109
|
+
if (this.isVisible(run.record))
|
|
2110
|
+
return true;
|
|
2111
|
+
return [...this.agents.values()].some(record => isBusy(record) && this.isVisible(record));
|
|
1912
2112
|
}
|
|
1913
2113
|
/** Abort all running and queued agents immediately. */
|
|
1914
2114
|
abortAll() {
|
|
@@ -1940,9 +2140,11 @@ export class AgentManager {
|
|
|
1940
2140
|
* commit and branch that preserve the agent's changes — finishes before the
|
|
1941
2141
|
* process exits instead of racing it.
|
|
1942
2142
|
*/
|
|
1943
|
-
async awaitWorktreeCleanup(timeoutMs) {
|
|
2143
|
+
async awaitWorktreeCleanup(timeoutMs, ids) {
|
|
1944
2144
|
const pending = [];
|
|
1945
2145
|
for (const record of this.agents.values()) {
|
|
2146
|
+
if (ids !== undefined && !ids.has(record.id))
|
|
2147
|
+
continue;
|
|
1946
2148
|
const startup = this.startups.get(record.id);
|
|
1947
2149
|
if (!record.worktree && !startup)
|
|
1948
2150
|
continue;
|
|
@@ -1969,6 +2171,9 @@ export class AgentManager {
|
|
|
1969
2171
|
for (const record of this.agents.values()) {
|
|
1970
2172
|
if (record.status !== "running" && record.status !== "queued")
|
|
1971
2173
|
continue;
|
|
2174
|
+
// Another session's agents keep running; this session does not wait on them.
|
|
2175
|
+
if (!this.isVisible(record))
|
|
2176
|
+
continue;
|
|
1972
2177
|
// An agent whose worktree is still being created is "running" with no
|
|
1973
2178
|
// `promise` yet — without its startup the wait would return too early.
|
|
1974
2179
|
const startup = this.startups.get(record.id);
|
|
@@ -1980,7 +2185,8 @@ export class AgentManager {
|
|
|
1980
2185
|
// A stopped run still winding down holds its slot, and whatever is queued
|
|
1981
2186
|
// behind it starts only once it settles (or is abandoned).
|
|
1982
2187
|
for (const run of this.runs.values())
|
|
1983
|
-
|
|
2188
|
+
if (this.isVisible(run.record))
|
|
2189
|
+
pending.push(run.done);
|
|
1984
2190
|
if (pending.length === 0)
|
|
1985
2191
|
break;
|
|
1986
2192
|
await Promise.allSettled(pending);
|
|
@@ -2011,12 +2217,13 @@ export class AgentManager {
|
|
|
2011
2217
|
const sessions = [...this.agents.values()].filter(record => !this.released.has(record)).map(record => record.session);
|
|
2012
2218
|
this.agents.clear();
|
|
2013
2219
|
this.startups.clear();
|
|
2220
|
+
this.pendingArgs.clear();
|
|
2014
2221
|
if (pi) {
|
|
2015
2222
|
// Prune any orphaned git worktrees (crash recovery). Detached: dispose runs
|
|
2016
2223
|
// on the shutdown path, which cannot wait for git. Started before the awaited
|
|
2017
2224
|
// shutdown below rather than after it, so the git calls have that window to
|
|
2018
2225
|
// finish in instead of racing the process exit that follows.
|
|
2019
|
-
const prune = (repo) => { pruneWorktrees(pi, repo).catch(() => { }); };
|
|
2226
|
+
const prune = (repo) => { this.svc().pruneWorktrees(pi, repo).catch(() => { }); };
|
|
2020
2227
|
prune(process.cwd());
|
|
2021
2228
|
// Also prune repos that caller-supplied cwds created worktrees in — a clean
|
|
2022
2229
|
// exit with in-flight agents would otherwise leave stale registrations there.
|