@alphacoder-v0/pi-loops 0.22.8 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +664 -0
- package/CONTRACT.md +7 -7
- package/README.md +91 -63
- package/README.zh-CN.md +59 -39
- package/docs/cli.md +272 -83
- package/docs/configuration.md +86 -24
- package/docs/design.md +395 -56
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +37 -26
- package/docs/web-ui-parity.md +691 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +746 -125
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1301 -450
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +16 -5
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2260 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
package/src/host-entry.mjs
CHANGED
|
@@ -8,7 +8,9 @@ import { importTs } from "./ts-entry.mjs";
|
|
|
8
8
|
try {
|
|
9
9
|
await importTs(import.meta.url, "./host.ts");
|
|
10
10
|
} catch (err) {
|
|
11
|
-
// stdout and stderr are the host log; a line there is what `pi-loops
|
|
12
|
-
process.stderr.write(`pi-loops
|
|
13
|
-
|
|
11
|
+
// stdout and stderr are the host log; a line there is what `pi-loops start` points a person at.
|
|
12
|
+
process.stderr.write(`pi-loops: ${err?.message ?? err}\n`);
|
|
13
|
+
// 78 (EX_CONFIG): it cannot run here — the package or pi is not where it was. A systemd unit with
|
|
14
|
+
// RestartPreventExitStatus=78 does not restart it, where a crash (1) would be restarted.
|
|
15
|
+
process.exit(78);
|
|
14
16
|
}
|
package/src/host-runtime.ts
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the
|
|
3
|
-
* TriggerRuntime as the extension, wired for a process that has no chat
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* What the always-on host runs (see host.ts for the process shell): the same LoopScheduler and
|
|
3
|
+
* TriggerRuntime as the extension, wired for a process that has no chat. It runs every loop and
|
|
4
|
+
* checks every rule; a promotion is a request addressed to the conversation that created the rule
|
|
5
|
+
* (src/requests.ts): its holder delivers it, and this process does when it is closed. Control-plane
|
|
6
|
+
* operations by sub-agents are audited into `triggers-audit.jsonl`. Injected so it can be unit-tested.
|
|
6
7
|
*/
|
|
7
|
-
import
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import { SessionManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
10
|
import * as path from "node:path";
|
|
9
11
|
import type { LoopsConfig } from "./config.ts";
|
|
10
12
|
import { HookRunner, runEndEvent, runStartEvent } from "./hooks.ts";
|
|
11
13
|
import { PI_BUILTIN_TOOL_NAMES } from "./mcp.ts";
|
|
12
14
|
import { redact } from "./redact.ts";
|
|
13
|
-
import
|
|
14
|
-
import {
|
|
15
|
+
import { keepNotice, parseRingNote, type Notice } from "./host-control-channel.ts";
|
|
16
|
+
import type { RunnerResult, SubagentRequest, SubagentRunner } from "./runner.ts";
|
|
17
|
+
import { DELIVERY_JOB_ID, type LeaseHolder, LeaseStore, closedConversation, liveHolderPid, procStart, sameHolder } from "./lease.ts";
|
|
18
|
+
import { CEILING, type EngineSinks, type Executor, type ExecutorView, type FileMark, type QuietMark, bury, changesSomething, conversationHas, conversationView, decideDrain, drain, promotionTarget, promotionView, quietMark, ruleResultRow, sessionFileEntries, stillQuiet } from "./engine.ts";
|
|
19
|
+
import { RequestStore } from "./requests.ts";
|
|
20
|
+
import { PI_LOOPS_VERSION } from "./version.ts";
|
|
21
|
+
import type { PresenceEntry } from "./presence.ts";
|
|
22
|
+
import { DEFAULT_RUN_TIMEOUT_MS, type HeldSession, LoopScheduler, type PlainJobLeases, STOPPED_BY_PERSON, type SessionSnapshot } from "./scheduler.ts";
|
|
23
|
+
import { stamp } from "./schedule.ts";
|
|
15
24
|
import { SubagentSlots } from "./slots.ts";
|
|
16
25
|
import { type LoopJob, newId } from "./store.ts";
|
|
17
26
|
import { type ToolHost, automationTools, createLoopJob } from "./tools.ts";
|
|
18
|
-
import { TriggerRuntime } from "./trigger-runtime.ts";
|
|
27
|
+
import { TriggerRuntime, promotionToInbox } from "./trigger-runtime.ts";
|
|
19
28
|
import { auditCronFinish, auditCronStart, TriggerStore, controlPlanePreflight } from "./triggers.ts";
|
|
29
|
+
import { shortSessionId } from "./session-head.ts";
|
|
20
30
|
|
|
21
31
|
export interface HostRuntimeDeps {
|
|
22
32
|
dir: string;
|
|
@@ -27,12 +37,18 @@ export interface HostRuntimeDeps {
|
|
|
27
37
|
/** MCP tool definitions of the host's own (user-level) servers, for every sub-session. */
|
|
28
38
|
mcpTools: () => ToolDefinition<any, any>[];
|
|
29
39
|
/** That project's own MCP servers, connected on demand: the host has no project of its own. */
|
|
30
|
-
projectMcpTools?: (cwd: string, taken: Set<string
|
|
40
|
+
projectMcpTools?: (cwd: string, taken: Set<string>, trusted?: boolean) => Promise<ToolDefinition<any, any>[]>;
|
|
31
41
|
/** Whether pi has a saved trust decision for exactly this directory (src/trust.ts). Default: no. */
|
|
32
42
|
isProjectTrusted?: (cwd: string) => boolean;
|
|
43
|
+
/** pi's own saved decision for a conversation's directory or a parent: what a held turn there may load. */
|
|
44
|
+
ownerTrusts?: (cwd: string) => boolean;
|
|
33
45
|
log: (message: string) => void;
|
|
34
|
-
/**
|
|
35
|
-
|
|
46
|
+
/** Does pi still have this session? Plain jobs of deleted sessions are parked by the host's sweep. */
|
|
47
|
+
sessionExists?: (sessionId: string) => boolean;
|
|
48
|
+
/** Every tick, before the rules are checked (the legacy fence lives here). */
|
|
49
|
+
onTick?: (now: number) => void;
|
|
50
|
+
/** Is this conversation open in the browser page? Then its pi writes the file, and this process never holds it. */
|
|
51
|
+
pageHolds?: (sessionId: string) => boolean;
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
export interface HostRuntime {
|
|
@@ -40,14 +56,176 @@ export interface HostRuntime {
|
|
|
40
56
|
triggers: TriggerRuntime;
|
|
41
57
|
/** The tools a sub-session started by this host gets (its cwd and model, not the host's). */
|
|
42
58
|
customTools: (req: SubagentRequest) => Promise<ToolDefinition<any, any>[]>;
|
|
59
|
+
/** Say a warning to the windows of `cwd`'s project (or every window, without one); see `notices`. */
|
|
60
|
+
notice(text: string, cwd?: string): void;
|
|
61
|
+
/** The latest warnings, for the snapshot a window fetches on each tick. */
|
|
62
|
+
notices(): Notice[];
|
|
63
|
+
/** The doorbell: deliver what is queued for a closed conversation now, not on the next tick. Returns at once. */
|
|
64
|
+
ring(sessionId: string): void;
|
|
65
|
+
/** The turn this process is running in the conversation, if any: the browser page waits for it. */
|
|
66
|
+
heldTurn(sessionId: string): { runId: string; label: string } | undefined;
|
|
43
67
|
start(): void;
|
|
44
68
|
stop(): Promise<void>;
|
|
45
69
|
}
|
|
46
70
|
|
|
71
|
+
/** Rings one session may answer between two ticks; a burst of rows is delivered by one ring, so this is far above any real one. */
|
|
72
|
+
const RINGS_PER_TICK = 60;
|
|
73
|
+
|
|
47
74
|
export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
48
75
|
const { dir, log } = deps;
|
|
76
|
+
const kept: Notice[] = [];
|
|
77
|
+
let noticeSeq = 0;
|
|
78
|
+
let budgetSaid: string | undefined;
|
|
79
|
+
const notice = (text: string, cwd?: string) => {
|
|
80
|
+
noticeSeq = keepNotice(kept, noticeSeq, text, cwd, Date.now());
|
|
81
|
+
};
|
|
49
82
|
let triggers: TriggerRuntime;
|
|
50
|
-
|
|
83
|
+
const leases = new LeaseStore(dir);
|
|
84
|
+
const requests = new RequestStore(dir);
|
|
85
|
+
/** The host as a lease holder: it holds a closed conversation for one piece of work, then lets go. */
|
|
86
|
+
const ownStart = procStart(process.pid);
|
|
87
|
+
const me = (busy?: LeaseHolder["busy"]): LeaseHolder => ({ kind: "host", pid: process.pid, instance: "host", procStart: ownStart, acquiredAt: stamp(), pushServers: [], ...(busy ? { busy } : {}) });
|
|
88
|
+
const fileMtime = (file: string | undefined): number | undefined => {
|
|
89
|
+
if (!file) return undefined;
|
|
90
|
+
try {
|
|
91
|
+
return fs.statSync(file).mtimeMs;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined; // gone: nothing for the quiet guard to remember
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const fileMark = (file: string): FileMark => {
|
|
97
|
+
try {
|
|
98
|
+
const { size, mtimeMs } = fs.statSync(file);
|
|
99
|
+
return { size, mtimeMs };
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined; // not there: a mark of its own, so its appearance is a change
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/** Per closed conversation, the quiet mark of its last "nothing to do" (src/engine.ts quietMark). */
|
|
105
|
+
const quiet = new Map<string, QuietMark>();
|
|
106
|
+
/** Open in a pi-loops 0.22 window, which fires its plain jobs without a lease: never the host's to hold. */
|
|
107
|
+
let legacyOpen = (_sessionId: string): boolean => false;
|
|
108
|
+
const heldLeases: PlainJobLeases = {
|
|
109
|
+
heldEpoch: (sessionId) => (leases.holds(sessionId, me()) ? leases.read(sessionId)?.epoch : undefined),
|
|
110
|
+
holderOf: (sessionId) => liveHolderPid(leases, sessionId),
|
|
111
|
+
acquireForWork: (sessionId, runId, jobId) => {
|
|
112
|
+
if (legacyOpen(sessionId) || deps.pageHolds?.(sessionId)) return undefined;
|
|
113
|
+
const got = leases.acquireForHost(sessionId, me({ runId, jobId, since: stamp() }));
|
|
114
|
+
if (!("lease" in got) || !got.lease.sessionFile) return undefined;
|
|
115
|
+
return { sessionId, epoch: got.lease.epoch, sessionFile: got.lease.sessionFile, cwd: got.lease.cwd };
|
|
116
|
+
},
|
|
117
|
+
// With the epoch it took, and the mtime it left: see LeaseStore.release.
|
|
118
|
+
releaseWork: (sessionId, epoch) => void leases.release(sessionId, me(), { epoch, fileMtimeMs: fileMtime(leases.read(sessionId)?.sessionFile) }),
|
|
119
|
+
};
|
|
120
|
+
/** A plain job the host may fire: a conversation pi-loops knows the file of, that nobody holds and the page does not have open. */
|
|
121
|
+
const canHold = (sessionId: string | undefined): boolean => !(sessionId !== undefined && deps.pageHolds?.(sessionId)) && closedConversation(leases, sessionId, legacyOpen);
|
|
122
|
+
|
|
123
|
+
/** The engine's sinks here: the jobs the rows name, the inbox for dead letters, the audit. */
|
|
124
|
+
const sinks = (): EngineSinks => ({ jobs: { exists: (id) => scheduler.store.load().some((j) => j.id === id), enabled: (id) => scheduler.store.load().find((j) => j.id === id)?.enabled ?? false, goalActive: () => false }, inbox: scheduler.inbox, audit: triggers.store });
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The held turns this process is in, by session: the run (the lease's `busy.runId`), when it
|
|
128
|
+
* started, and whether the runner has the file (`writing`). A stop may name the run from the turn's
|
|
129
|
+
* own first drain on; nothing is appended beside the runner once it writes.
|
|
130
|
+
*/
|
|
131
|
+
const turnOpen = new Map<string, { requestId: string; startedAt: string; writing: boolean; label: string }>();
|
|
132
|
+
/**
|
|
133
|
+
* The engine's executor while this process holds a closed conversation: an `append` row is written
|
|
134
|
+
* into the file at once, each one once (the `[Trigger <id>]` already in the file is the check).
|
|
135
|
+
* No attended row runs here (`CEILING.held`); a turn row is not admitted yet either (held turns move
|
|
136
|
+
* onto rows in a later stage), so it waits for a window instead of failing here. While a turn of
|
|
137
|
+
* this process runs in the conversation its runner is writing the file, so nothing is appended
|
|
138
|
+
* beside it: only a stop or a cancel acts, and the inputs wait.
|
|
139
|
+
*/
|
|
140
|
+
const heldView = (sessionId: string): ExecutorView => {
|
|
141
|
+
const open = turnOpen.get(sessionId);
|
|
142
|
+
const writing = open?.writing ?? false;
|
|
143
|
+
return { kind: "held", ceiling: CEILING.held, admits: (row) => !writing && row.wake !== "turn", idle: !writing, running: open && { requestId: open.requestId, startedAt: open.startedAt }, inFlight: new Set(), connected: () => true, producerAlive: () => true, mine: () => false };
|
|
144
|
+
};
|
|
145
|
+
function heldExecutor(held: HeldSession): Executor {
|
|
146
|
+
const sm = SessionManager.open(held.sessionFile);
|
|
147
|
+
return {
|
|
148
|
+
holds: (sessionId) => leases.holds(sessionId, me(), held.epoch),
|
|
149
|
+
entries: () => conversationView(() => sm.getEntries()),
|
|
150
|
+
view: () => heldView(held.sessionId),
|
|
151
|
+
append: (row) => {
|
|
152
|
+
sm.appendCustomMessageEntry("pi-loops:trigger", row.payload?.text ?? "", true);
|
|
153
|
+
// Written into a closed conversation, so no screen showed it: the audit row says so. The
|
|
154
|
+
// "since you last looked" line counts the rule's finding in the inbox (src/seen.ts).
|
|
155
|
+
triggers.store.appendAudit({ cwd: row.payload?.cwd ?? "", type: "trigger_promotion", traceId: row.id, state: "appended", details: { session_id: held.sessionId } });
|
|
156
|
+
// And said in the project's open windows: the person is likely in another conversation of
|
|
157
|
+
// it now, where a rule's result used to land (0.22), and would not know to look there.
|
|
158
|
+
// Once an hour per conversation (keepNotice keeps one of each text an hour).
|
|
159
|
+
notice(`a rule's result was added to conversation ${shortSessionId(held.sessionId)}, which is closed: pi --session ${held.sessionFile} shows it`, row.payload?.cwd);
|
|
160
|
+
},
|
|
161
|
+
turn: () => {
|
|
162
|
+
throw new Error("the background process does not run turn requests yet");
|
|
163
|
+
},
|
|
164
|
+
// As `/triggers abort` does: the run's AbortController, through the scheduler.
|
|
165
|
+
abort: () => {
|
|
166
|
+
const run = turnOpen.get(held.sessionId);
|
|
167
|
+
if (run) scheduler.abortRun(run.requestId);
|
|
168
|
+
},
|
|
169
|
+
// Never asked: nothing is in flight here (the view's set is empty), a row is in the file or not.
|
|
170
|
+
abortWhenStarted: () => undefined,
|
|
171
|
+
// Never asked either: a person row is attended, above this executor's ceiling.
|
|
172
|
+
refused: () => undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Requests queued for a conversation this process holds: delivered now, while it holds it. */
|
|
177
|
+
async function drainInto(held: HeldSession): Promise<void> {
|
|
178
|
+
await drain(held.sessionId, requests, heldExecutor(held), Date.now(), sinks());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One plain job's turn in a closed conversation (src/scheduler.ts launchHeld). */
|
|
182
|
+
async function heldTurn(job: LoopJob, prompt: string, runId: string, held: HeldSession, signal: AbortSignal): Promise<{ preempted: boolean; result: RunnerResult; landed: boolean }> {
|
|
183
|
+
const stillHolds = () => leases.holds(held.sessionId, me(), held.epoch);
|
|
184
|
+
// The run exists from here: a stop that arrives during this first drain names it.
|
|
185
|
+
// What the page's waiting line calls it: a job's id is 32 characters no person typed.
|
|
186
|
+
const open = { requestId: runId, startedAt: scheduler.runningRuns().find((r) => r.runId === runId)?.startedAt ?? stamp(), label: job.name ?? runId.slice(0, 12) };
|
|
187
|
+
turnOpen.set(held.sessionId, { ...open, writing: false });
|
|
188
|
+
log(`cron ${job.name ?? job.id}: turn ${runId.slice(0, 12)} in session ${held.sessionId.slice(0, 8)}`);
|
|
189
|
+
let result: RunnerResult;
|
|
190
|
+
try {
|
|
191
|
+
try {
|
|
192
|
+
await drainInto(held);
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
log(`requests for session ${held.sessionId.slice(0, 8)}: ${err?.message ?? err}`);
|
|
195
|
+
}
|
|
196
|
+
if (signal.aborted) {
|
|
197
|
+
// Stopped before it began: nothing to run, and no model call to start for a turn already over.
|
|
198
|
+
log(`cron ${job.name ?? job.id}: turn ${runId.slice(0, 12)} stopped before it began`);
|
|
199
|
+
return { preempted: false, result: { ok: false, exitCode: 1, timedOut: false, text: "", errorMessage: "aborted", stopReason: "aborted", usage: { input: 0, output: 0, cost: 0, turns: 0 } }, landed: false };
|
|
200
|
+
}
|
|
201
|
+
// As the conversation's owner: pi's own trust for its directory, read now, and the job's declared tools.
|
|
202
|
+
// Without the conversation's own directory (an older lease), the job's cwd could be one a model
|
|
203
|
+
// chose: no owner trust then.
|
|
204
|
+
const cwd = held.cwd ?? job.cwd;
|
|
205
|
+
turnOpen.set(held.sessionId, { ...open, writing: true });
|
|
206
|
+
// No model or thinking level: the turn is the conversation's own and runs on what the
|
|
207
|
+
// conversation is on, as its window turn does (a 0.22 job's captured `model` was never used).
|
|
208
|
+
// The conversation is the turn's parent session, so the tools act for it: `cron_create
|
|
209
|
+
// stateful:false` binds to it, `all_projects` lists as a window's does.
|
|
210
|
+
result = await deps.runner({ cwd, prompt, tools: job.tools, builtinTools: job.builtinTools, trusted: held.cwd ? !!deps.ownerTrusts?.(held.cwd) : false, timeoutMs: job.timeoutMs ?? DEFAULT_RUN_TIMEOUT_MS, signal, hop: 1, kind: "held", jobId: job.id, runId, sessionFile: held.sessionFile, stillHolds, parentSessionId: held.sessionId, parentCwd: cwd });
|
|
211
|
+
} finally {
|
|
212
|
+
turnOpen.delete(held.sessionId);
|
|
213
|
+
}
|
|
214
|
+
// Taken back only if the turn did not complete: a window that arrives as the answer is saved has
|
|
215
|
+
// nothing to run again, and rolling the slot back would answer the same prompt twice.
|
|
216
|
+
const preempted = !result.ok && !stillHolds();
|
|
217
|
+
const byPerson = !result.ok && signal.aborted && signal.reason === STOPPED_BY_PERSON;
|
|
218
|
+
log(`cron ${job.name ?? job.id}: ${preempted ? "a window took the session back; its turn stopped" : result.ok ? "turn done" : byPerson ? `turn stopped by you (from ${scheduler.stopFrom(runId) === "page" ? "the page" : "a terminal"})` : `turn FAILED (${redact(result.errorMessage ?? "")})`}`);
|
|
219
|
+
// Read from the file, not from the result: a turn cut off after pi wrote the prompt has landed
|
|
220
|
+
// it, and the row it came from is done either way.
|
|
221
|
+
let landed = result.ok;
|
|
222
|
+
try {
|
|
223
|
+
landed = conversationHas(SessionManager.open(held.sessionFile).getEntries(), runId);
|
|
224
|
+
} catch (err: any) {
|
|
225
|
+
log(`cron ${job.name ?? job.id}: could not read session ${held.sessionId.slice(0, 8)} after its turn: ${err?.message ?? err}`);
|
|
226
|
+
}
|
|
227
|
+
return { preempted, result, landed };
|
|
228
|
+
}
|
|
51
229
|
|
|
52
230
|
/* ------------------------------------------------------ lifecycle hooks */
|
|
53
231
|
|
|
@@ -106,10 +284,13 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
106
284
|
getSession: deps.session,
|
|
107
285
|
getSettings: () => ({ maxConcurrentRuns: deps.config().maxConcurrentRuns, catchUp: deps.config().cronCatchUp, dailyBudgetUsd: deps.config().dailyBudgetUsd }),
|
|
108
286
|
runner: deps.runner,
|
|
109
|
-
//
|
|
287
|
+
// Every loop, and the plain jobs of closed conversations: a plain job's result is a message in
|
|
288
|
+
// its conversation, so the host fires one only where no window holds it.
|
|
289
|
+
owns: (job) => job.stateful || canHold(job.sessionId),
|
|
290
|
+
leases: heldLeases,
|
|
291
|
+
sessionExists: deps.sessionExists,
|
|
110
292
|
hooks: {
|
|
111
|
-
|
|
112
|
-
onInject: () => undefined,
|
|
293
|
+
onHeldTurn: heldTurn,
|
|
113
294
|
onRunStart: (job, runId) => {
|
|
114
295
|
// The same rows the interactive extension writes: without them `/triggers audit` is
|
|
115
296
|
// blank for every hour the host had the clock.
|
|
@@ -124,7 +305,7 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
124
305
|
onCatchUp: (job) => log(`loop ${job.name ?? job.id}: catching up a missed tick`),
|
|
125
306
|
onRunFinished: ({ job, record, result }) => {
|
|
126
307
|
auditCronFinish(triggers.store, job, record, result.stopReason === "aborted");
|
|
127
|
-
log(`loop ${job.name ?? job.id}: ${record.ok ? "ok" : `FAILED (${redact(record.error ?? "")})`} · ${record.findings} finding(s)${record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : ""}`);
|
|
308
|
+
log(`loop ${job.name ?? job.id}: ${record.ok ? "ok" : record.stoppedBy === "person" ? `stopped by you (from ${record.stoppedFrom === "page" ? "the page" : "a terminal"})` : `FAILED (${redact(record.error ?? "")})`} · ${record.findings} finding(s)${record.usage?.cost ? ` · $${record.usage.cost.toFixed(3)}` : ""}`);
|
|
128
309
|
const hooks = runHooks.get(record.runId);
|
|
129
310
|
if (!hooks) return;
|
|
130
311
|
// `$PI_RUN_OK` alone answers "did last night's loop fail" — the thing the host exists to
|
|
@@ -134,25 +315,242 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
134
315
|
const forget = () => void runHooks.delete(record.runId);
|
|
135
316
|
void hooks.fire(runEndEvent(job, record)).then(forget, forget);
|
|
136
317
|
},
|
|
137
|
-
onTick: async (now
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
318
|
+
onTick: async (now) => {
|
|
319
|
+
deps.onTick?.(now);
|
|
320
|
+
releaseStrayLeases(now);
|
|
321
|
+
await deliverToClosed();
|
|
322
|
+
await triggers.tick(now);
|
|
323
|
+
},
|
|
324
|
+
log: (msg, level, job) => {
|
|
325
|
+
log(redact(msg));
|
|
326
|
+
if (level !== "info") notice(msg, job?.cwd);
|
|
327
|
+
},
|
|
328
|
+
// A loop failing night after night: said in its project's windows, as it was when loops ran in one.
|
|
329
|
+
onSchedulerError: (msg, job) => {
|
|
330
|
+
log(redact(msg));
|
|
331
|
+
notice(msg, job?.cwd);
|
|
332
|
+
},
|
|
333
|
+
onBudgetExceeded: (spent, cap) => {
|
|
334
|
+
// Each due job reports it; a person needs it once a day.
|
|
335
|
+
const day = stamp().slice(0, 10);
|
|
336
|
+
if (budgetSaid === day) return;
|
|
337
|
+
budgetSaid = day;
|
|
338
|
+
const message = `today's automation has cost $${spent.toFixed(2)} of the $${cap.toFixed(2)} budget; dispatching is paused until tomorrow or a higher [limits] daily_budget_usd`;
|
|
339
|
+
log(message);
|
|
340
|
+
notice(message);
|
|
147
341
|
},
|
|
148
|
-
log: (msg) => log(redact(msg)),
|
|
149
342
|
},
|
|
150
343
|
});
|
|
151
344
|
|
|
152
|
-
|
|
153
|
-
|
|
345
|
+
legacyOpen = (sessionId) => scheduler.presenceList().some((e: PresenceEntry) => e.sessionId === sessionId && !e.leases && e.kind !== "host");
|
|
346
|
+
|
|
347
|
+
const toInbox = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }, forSession?: string) => {
|
|
348
|
+
// The check decided this result's finding already (TriggerRuntime's inbox); a push with no rule did not.
|
|
349
|
+
await promotionToInbox(scheduler.inbox, triggers.store, content, trigger, { cwd: trigger.cwd ?? "", sessionId: forSession });
|
|
154
350
|
return "inbox" as const;
|
|
155
351
|
};
|
|
352
|
+
/**
|
|
353
|
+
* A rule's result is a request addressed to a conversation (src/engine.ts promotionTarget): the one
|
|
354
|
+
* that created the rule while a window holds it, else one a window holds in the rule's project,
|
|
355
|
+
* else the closed creating conversation, which this process appends to while it holds it for that
|
|
356
|
+
* one write. A closed conversation this process cannot take this instant (written moments ago,
|
|
357
|
+
* busy) keeps the row for a later tick. The inbox is for a result no conversation can take.
|
|
358
|
+
*/
|
|
359
|
+
const promote = async (content: string, trigger: { sourceLabel: string; traceId: string; cwd?: string }, forSession?: string): Promise<"queued" | "chat" | "inbox"> => {
|
|
360
|
+
const route = promotionTarget(forSession, promotionView(leases, trigger.cwd, legacyOpen));
|
|
361
|
+
if (route.to === "inbox") return toInbox(content, trigger, route.sessionId);
|
|
362
|
+
requests.append(ruleResultRow(route.sessionId, content, trigger, { pid: process.pid, instance: "host", procStart: ownStart, version: PI_LOOPS_VERSION }));
|
|
363
|
+
if (!canHold(route.sessionId)) return "queued";
|
|
364
|
+
return (await deliverQueued(route.sessionId, trigger.traceId)) ? "chat" : "queued";
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Take a closed conversation for one write and deliver what is queued for it. True when `traceId`
|
|
369
|
+
* (if given) is now in the conversation. The tick calls it for every closed conversation with a
|
|
370
|
+
* queue, so a result queued while the file was busy, or for a window that quit before reading it,
|
|
371
|
+
* still arrives.
|
|
372
|
+
*/
|
|
373
|
+
async function deliverQueued(sessionId: string, traceId?: string): Promise<boolean> {
|
|
374
|
+
const held = heldLeases.acquireForWork?.(sessionId, traceId ?? "requests", DELIVERY_JOB_ID);
|
|
375
|
+
if (!held) return false;
|
|
376
|
+
try {
|
|
377
|
+
await drainInto(held);
|
|
378
|
+
if (traceId === undefined) return true;
|
|
379
|
+
const delivered = conversationHas(SessionManager.open(held.sessionFile).getEntries(), traceId);
|
|
380
|
+
if (delivered) log(`trigger ${traceId.slice(0, 8)}: appended to session ${sessionId.slice(0, 8)}`);
|
|
381
|
+
return delivered;
|
|
382
|
+
} catch (err: any) {
|
|
383
|
+
log(`requests for session ${sessionId.slice(0, 8)}: ${err?.message ?? err}`);
|
|
384
|
+
// Giving the lease back below rings this process again; with the same failure waiting
|
|
385
|
+
// (an inbox it cannot write), that ring would fail the same way, for ever.
|
|
386
|
+
quietUntilTick.add(sessionId);
|
|
387
|
+
return false;
|
|
388
|
+
} finally {
|
|
389
|
+
heldLeases.releaseWork?.(sessionId, held.epoch);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* A conversation this process still holds for a turn that is no longer running: the release at
|
|
395
|
+
* the turn's end failed (a lock timeout, a full disk), and nothing ran or was delivered there again
|
|
396
|
+
* until a restart. Given back here, a minute after the turn began, which no turn's setup takes.
|
|
397
|
+
*/
|
|
398
|
+
function releaseStrayLeases(now: number): void {
|
|
399
|
+
const running = new Set(scheduler.runningRuns().map((r) => r.runId));
|
|
400
|
+
for (const lease of leases.list()) {
|
|
401
|
+
const busy = lease.holder?.busy;
|
|
402
|
+
if (!lease.holder || !busy || !sameHolder(lease.holder, me()) || running.has(busy.runId) || now - Date.parse(busy.since) < 60_000) continue;
|
|
403
|
+
try {
|
|
404
|
+
leases.release(lease.sessionId, me(), { epoch: lease.epoch, fileMtimeMs: fileMtime(lease.sessionFile) });
|
|
405
|
+
log(`gave back session ${lease.sessionId.slice(0, 8)}, held for run ${busy.runId} that is no longer running`);
|
|
406
|
+
} catch (err: any) {
|
|
407
|
+
log(`lease ${lease.sessionId.slice(0, 8)}: ${err?.message ?? err}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* What is queued for one conversation nobody has open: delivered by this process, not left waiting
|
|
414
|
+
* for a resume. A conversation whose file is gone can never take it, so its rows become dead
|
|
415
|
+
* letters in the inbox, marked for it, instead of waiting there for ever.
|
|
416
|
+
*
|
|
417
|
+
* Writes nothing when the drain would change nothing. Taking the lease is itself a write, and this
|
|
418
|
+
* process hears its own writes as rings (`watch` below): a delivery that took the lease only to
|
|
419
|
+
* find every row waiting would ring itself into the next one, for ever.
|
|
420
|
+
*/
|
|
421
|
+
async function deliverSession(sessionId: string): Promise<void> {
|
|
422
|
+
const rows = requests.list(sessionId);
|
|
423
|
+
if (!rows.length) {
|
|
424
|
+
quiet.delete(sessionId);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// A turn of this process's own is running there: the conversation is held already, for that
|
|
428
|
+
// turn, so a stop or cancel is applied through that lease, with the epoch it took, and nothing
|
|
429
|
+
// is taken a second time. The executor admits no input while the runner has the file.
|
|
430
|
+
const lease = leases.read(sessionId);
|
|
431
|
+
if (turnOpen.has(sessionId) && lease?.holder && sameHolder(lease.holder, me()) && lease.sessionFile) {
|
|
432
|
+
await drainInto({ sessionId, epoch: lease.epoch, sessionFile: lease.sessionFile, cwd: lease.cwd });
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!canHold(sessionId)) {
|
|
436
|
+
if (liveHolderPid(leases, sessionId) !== undefined || legacyOpen(sessionId) || deps.pageHolds?.(sessionId)) return; // its holder drains it
|
|
437
|
+
const file = leases.read(sessionId)?.sessionFile;
|
|
438
|
+
if (file && fs.existsSync(file)) return; // there, only busy or recently written: a later tick
|
|
439
|
+
const buried = await bury(sessionId, requests, sinks(), "its conversation is gone");
|
|
440
|
+
if (buried.length) log(`${buried.length} queued request(s) for session ${sessionId.slice(0, 8)} went to the inbox: its conversation is gone`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// The same decision the drain makes, read without the lease (a read needs none). Marked before
|
|
444
|
+
// the read, so a write that lands during it shows as a change on the next tick, not a miss.
|
|
445
|
+
const file = leases.read(sessionId)?.sessionFile;
|
|
446
|
+
if (!file) return;
|
|
447
|
+
const files = [fileMark(file), fileMark(path.join(dir, "jobs.json"))];
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
if (stillQuiet(quiet.get(sessionId), files, rows, now)) return;
|
|
450
|
+
quiet.delete(sessionId);
|
|
451
|
+
const entries = sessionFileEntries(file);
|
|
452
|
+
if (!changesSomething(decideDrain(rows, conversationView(() => entries), sinks().jobs, heldView(sessionId), now))) {
|
|
453
|
+
quiet.set(sessionId, quietMark(files, rows, now));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
await deliverQueued(sessionId);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Every closed conversation with something queued, on the tick: the floor under the doorbell, so a
|
|
461
|
+
* ring that was lost costs at most one tick. One conversation's failure (a lock that timed out)
|
|
462
|
+
* does not keep the others, or the rule checks after this, from their tick.
|
|
463
|
+
*/
|
|
464
|
+
async function deliverToClosed(): Promise<void> {
|
|
465
|
+
quietUntilTick.clear();
|
|
466
|
+
rungSinceTick.clear();
|
|
467
|
+
// Image files whose row is gone (a crash between the two writes): one process sweeps them, here.
|
|
468
|
+
try {
|
|
469
|
+
const swept = requests.sweepImages();
|
|
470
|
+
if (swept.length) log(`removed ${swept.length} image file(s) no request names any more`);
|
|
471
|
+
} catch (err: any) {
|
|
472
|
+
log(`requests: ${err?.message ?? err}`);
|
|
473
|
+
}
|
|
474
|
+
for (const sessionId of requests.sessions()) {
|
|
475
|
+
try {
|
|
476
|
+
await deliverSession(sessionId);
|
|
477
|
+
} catch (err: any) {
|
|
478
|
+
log(`requests for session ${sessionId.slice(0, 8)}: ${err?.message ?? err}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/* ------------------------------------------------------------ the doorbell */
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Rings arrive in bursts (every append, every lease write is one), so a session has at most one
|
|
487
|
+
* delivery running and one more to run after it: a ring during a delivery is kept as that one
|
|
488
|
+
* rerun, which sees whatever the running one read too early to see. Nothing waits on a timer.
|
|
489
|
+
*/
|
|
490
|
+
const delivering = new Set<string>();
|
|
491
|
+
const ringAgain = new Set<string>();
|
|
492
|
+
/**
|
|
493
|
+
* The bell is never the only way a row arrives, so it may stop ringing: after a delivery that
|
|
494
|
+
* failed, and past a number of rings no real burst reaches between two ticks. Either way the
|
|
495
|
+
* session is the tick's until then. This bounds a loop of self-rings that a check above missed;
|
|
496
|
+
* the timers it would starve include the tick's own.
|
|
497
|
+
*/
|
|
498
|
+
const quietUntilTick = new Set<string>();
|
|
499
|
+
const rungSinceTick = new Map<string, number>();
|
|
500
|
+
function ring(sessionId: string): void {
|
|
501
|
+
if (quietUntilTick.has(sessionId)) return;
|
|
502
|
+
const rung = (rungSinceTick.get(sessionId) ?? 0) + 1;
|
|
503
|
+
rungSinceTick.set(sessionId, rung);
|
|
504
|
+
if (rung > RINGS_PER_TICK) {
|
|
505
|
+
quietUntilTick.add(sessionId);
|
|
506
|
+
log(`doorbell for session ${sessionId.slice(0, 8)}: rang ${RINGS_PER_TICK} times since the last tick; left to the tick`);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (delivering.has(sessionId)) {
|
|
510
|
+
ringAgain.add(sessionId);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
delivering.add(sessionId);
|
|
514
|
+
deliverSession(sessionId)
|
|
515
|
+
.catch((err: any) => log(`requests for session ${sessionId.slice(0, 8)}: ${err?.message ?? err}`))
|
|
516
|
+
.then(() => {
|
|
517
|
+
delivering.delete(sessionId);
|
|
518
|
+
if (ringAgain.delete(sessionId)) ring(sessionId);
|
|
519
|
+
})
|
|
520
|
+
.catch((err: any) => log(`doorbell: ${err?.message ?? err}`));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The file name is the whole message: `<session>.jsonl` in `requests/` is a row written for it,
|
|
525
|
+
* `<session>.json` in `leases/` a conversation given back, or taken. Either rings the session.
|
|
526
|
+
* fs.watch can miss events and is best-effort by design; the tick still delivers, so a watcher
|
|
527
|
+
* that fails to start, or fails later, is logged and not restarted.
|
|
528
|
+
*/
|
|
529
|
+
const watchers: fs.FSWatcher[] = [];
|
|
530
|
+
function watch(): void {
|
|
531
|
+
if (watchers.length) return;
|
|
532
|
+
const stores: Array<{ sub: "requests" | "leases"; ext: string; kind: "request" | "lease" }> = [
|
|
533
|
+
{ sub: "requests", ext: ".jsonl", kind: "request" },
|
|
534
|
+
{ sub: "leases", ext: ".json", kind: "lease" },
|
|
535
|
+
];
|
|
536
|
+
for (const { sub, ext, kind } of stores) {
|
|
537
|
+
const where = path.join(dir, sub);
|
|
538
|
+
try {
|
|
539
|
+
fs.mkdirSync(where, { recursive: true });
|
|
540
|
+
const watcher = fs.watch(where, (_event, name) => {
|
|
541
|
+
const file = String(name ?? "");
|
|
542
|
+
if (!file.endsWith(ext)) return;
|
|
543
|
+
const note = parseRingNote(`${kind}:${file.slice(0, -ext.length)}`);
|
|
544
|
+
if (note) ring(note.sessionId);
|
|
545
|
+
});
|
|
546
|
+
watcher.on("error", (err) => log(`${sub} watch: ${err?.message ?? err}`));
|
|
547
|
+
watchers.push(watcher);
|
|
548
|
+
} catch (err: any) {
|
|
549
|
+
log(`${sub} watch: ${err?.message ?? err}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
156
554
|
triggers = new TriggerRuntime({
|
|
157
555
|
store: new TriggerStore(dir),
|
|
158
556
|
jobStore: scheduler.store,
|
|
@@ -163,24 +561,25 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
163
561
|
dedupFile: path.join(dir, "dedup.json"),
|
|
164
562
|
slots,
|
|
165
563
|
budget: () => scheduler.budgetState(),
|
|
166
|
-
|
|
167
|
-
presence: () => scheduler.presenceList(),
|
|
168
|
-
isLeader: () => scheduler.isLeader,
|
|
564
|
+
inbox: scheduler.inbox,
|
|
169
565
|
hooks: {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
onInjectAndRun: toInbox,
|
|
566
|
+
onPromote: promote,
|
|
567
|
+
// A push that injects and runs reaches the host only when no window has its server (host.ts): the inbox.
|
|
568
|
+
onInjectAndRun: (prompt, trigger) => toInbox(prompt, trigger),
|
|
173
569
|
onFinished: (o) => log(`trigger ${o.trigger.traceId.slice(0, 8)} ${o.delivery} ${o.ok ? "ok" : `FAILED (${redact(o.error ?? "")})`}${o.matchedRules.length ? ` matched ${o.matchedRules.length}` : ""}`),
|
|
174
570
|
log: (msg) => log(redact(msg)),
|
|
175
571
|
},
|
|
176
572
|
});
|
|
177
573
|
|
|
178
574
|
/** The tools' host for one sub-session: that run's cwd and model, not the process defaults. */
|
|
179
|
-
const toolHostFor = (req: Pick<SubagentRequest, "cwd" | "model" | "thinking">): ToolHost => {
|
|
575
|
+
const toolHostFor = (req: Pick<SubagentRequest, "cwd" | "model" | "thinking" | "builtinTools" | "tools" | "kind" | "parentSessionId">): ToolHost => {
|
|
576
|
+
const held = req.kind === "held";
|
|
180
577
|
const host: ToolHost = {
|
|
181
578
|
scheduler,
|
|
182
579
|
triggers,
|
|
183
|
-
|
|
580
|
+
// A job a run makes gets no more built-in tools than that run (createLoopJob trims to them), and no trust of its own.
|
|
581
|
+
// A held turn's session is the conversation it runs in; a loop's run has none.
|
|
582
|
+
session: () => ({ ...deps.session(), sessionId: req.parentSessionId, cwd: req.cwd, model: req.model ?? deps.session().model, thinking: req.thinking ?? deps.session().thinking, builtinTools: req.builtinTools ?? req.tools?.filter((t) => PI_BUILTIN_TOOL_NAMES.includes(t)) ?? deps.session().builtinTools, trusted: false }),
|
|
184
583
|
createJob: (input, scope) => createLoopJob(host, input, scope),
|
|
185
584
|
// The cron_control_plane audit has no session to live in here; /triggers audit shows it instead.
|
|
186
585
|
cronControlAudit: (op, actor, before, after) => {
|
|
@@ -190,8 +589,9 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
190
589
|
log(`cron control plane: ${op} by ${actor} ${job?.id ?? ""}`);
|
|
191
590
|
return id;
|
|
192
591
|
},
|
|
193
|
-
// Nobody can approve here: Prompt-class operations are denied fail-closed.
|
|
194
|
-
|
|
592
|
+
// Nobody can approve here: Prompt-class operations are denied fail-closed. A held turn is the
|
|
593
|
+
// conversation's own, so its refusal says where the person was not, not "sub-agent".
|
|
594
|
+
confirmTool: async (_ctx, request, atHop) => controlPlanePreflight({ hop: held ? 0 : Math.max(1, atHop), hasUI: false, held }, request.label),
|
|
195
595
|
refreshBadge: () => undefined,
|
|
196
596
|
};
|
|
197
597
|
return host;
|
|
@@ -200,15 +600,28 @@ export function createHostRuntime(deps: HostRuntimeDeps): HostRuntime {
|
|
|
200
600
|
return {
|
|
201
601
|
scheduler,
|
|
202
602
|
triggers,
|
|
603
|
+
notice,
|
|
604
|
+
notices: () => [...kept],
|
|
605
|
+
ring,
|
|
606
|
+
heldTurn: (sessionId) => {
|
|
607
|
+
const open = turnOpen.get(sessionId);
|
|
608
|
+
return open && { runId: open.requestId, label: open.label };
|
|
609
|
+
},
|
|
203
610
|
customTools: async (req) => {
|
|
204
611
|
const shared = deps.mcpTools();
|
|
205
|
-
|
|
612
|
+
// A held turn is the conversation's own turn, as in a window (hop 0, actor "tool"): the
|
|
613
|
+
// tools list and bind as they would there. A loop's run is a sub-agent's.
|
|
614
|
+
const automation = automationTools(req.kind === "held" ? { hop: 0, actor: "tool", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd } : { hop: req.hop, actor: "sub-agent", parentSessionId: req.parentSessionId, parentCwd: req.parentCwd }, toolHostFor(req));
|
|
206
615
|
const taken = new Set([...PI_BUILTIN_TOOL_NAMES, ...shared.map((t) => t.name), ...automation.map((t) => t.name)]);
|
|
207
|
-
const project = req.cwd ? ((await deps.projectMcpTools?.(req.cwd, taken)) ?? []) : [];
|
|
616
|
+
const project = req.cwd ? ((await deps.projectMcpTools?.(req.cwd, taken, req.trusted)) ?? []) : [];
|
|
208
617
|
return [...shared, ...project, ...automation];
|
|
209
618
|
},
|
|
210
|
-
start: () =>
|
|
619
|
+
start: () => {
|
|
620
|
+
scheduler.start();
|
|
621
|
+
watch();
|
|
622
|
+
},
|
|
211
623
|
stop: async () => {
|
|
624
|
+
for (const w of watchers.splice(0)) w.close();
|
|
212
625
|
await triggers.stop();
|
|
213
626
|
await scheduler.stop();
|
|
214
627
|
// The runs are over; their hooks may still be in flight. Same bounded wait as the
|