@fastagent-sh/fastagent 0.12.1 → 0.14.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/README.md +6 -4
- package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
- package/dist/channels/feishu/bootstrap-token.js +94 -0
- package/dist/channels/feishu/card.d.ts +32 -0
- package/dist/channels/feishu/card.js +66 -0
- package/dist/channels/feishu/cloud.d.ts +17 -0
- package/dist/channels/feishu/cloud.js +19 -0
- package/dist/channels/feishu/context-buffer.d.ts +46 -0
- package/dist/channels/feishu/context-buffer.js +133 -0
- package/dist/channels/feishu/crypto.d.ts +15 -0
- package/dist/channels/feishu/crypto.js +43 -0
- package/dist/channels/feishu/feishu-api.d.ts +108 -0
- package/dist/channels/feishu/feishu-api.js +325 -0
- package/dist/channels/feishu/feishu.d.ts +46 -0
- package/dist/channels/feishu/feishu.js +472 -0
- package/dist/channels/feishu/invoke-turn.d.ts +65 -0
- package/dist/channels/feishu/invoke-turn.js +157 -0
- package/dist/channels/feishu/model.d.ts +97 -0
- package/dist/channels/feishu/model.js +9 -0
- package/dist/channels/feishu/normalize.d.ts +22 -0
- package/dist/channels/feishu/normalize.js +132 -0
- package/dist/channels/feishu/owned-threads.d.ts +11 -0
- package/dist/channels/feishu/owned-threads.js +47 -0
- package/dist/channels/feishu/parse.d.ts +43 -0
- package/dist/channels/feishu/parse.js +65 -0
- package/dist/channels/feishu/preview.d.ts +36 -0
- package/dist/channels/feishu/preview.js +387 -0
- package/dist/channels/feishu/register-app.d.ts +70 -0
- package/dist/channels/feishu/register-app.js +141 -0
- package/dist/channels/feishu/register-webhook.d.ts +22 -0
- package/dist/channels/feishu/register-webhook.js +106 -0
- package/dist/channels/feishu/scaffold/channel.ts +41 -0
- package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
- package/dist/channels/feishu/seen.d.ts +5 -0
- package/dist/channels/feishu/seen.js +47 -0
- package/dist/channels/feishu/text.d.ts +13 -0
- package/dist/channels/feishu/text.js +63 -0
- package/dist/channels/lark/lark.d.ts +15 -0
- package/dist/channels/lark/lark.js +10 -0
- package/dist/channels/lark/onboard.d.ts +39 -0
- package/dist/channels/lark/onboard.js +58 -0
- package/dist/channels/lark/scaffold/channel.ts +39 -0
- package/dist/channels/lark/scaffold/lark-send.ts +87 -0
- package/dist/channels/registration.d.ts +15 -0
- package/dist/channels/registration.js +1 -0
- package/dist/channels/{telegram/state.js → state.js} +6 -4
- package/dist/channels/telegram/context-buffer.js +1 -1
- package/dist/channels/telegram/register-webhook.d.ts +4 -1
- package/dist/channels/telegram/register-webhook.js +17 -26
- package/dist/channels/telegram/telegram.js +2 -2
- package/dist/channels/telegram/turn-store.d.ts +8 -21
- package/dist/channels/telegram/turn-store.js +11 -130
- package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
- package/dist/channels/turn-store.d.ts +42 -0
- package/dist/channels/turn-store.js +139 -0
- package/dist/channels/wait-health.d.ts +6 -0
- package/dist/channels/wait-health.js +27 -0
- package/dist/cli/commands/add.d.ts +9 -0
- package/dist/cli/commands/add.js +142 -0
- package/dist/cli/commands/chat.d.ts +3 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/deploy.d.ts +13 -0
- package/dist/cli/commands/deploy.js +338 -0
- package/dist/cli/commands/dev.d.ts +11 -0
- package/dist/cli/commands/dev.js +76 -0
- package/dist/cli/commands/fire.d.ts +7 -0
- package/dist/cli/commands/fire.js +45 -0
- package/dist/cli/commands/info.d.ts +7 -0
- package/dist/cli/commands/info.js +108 -0
- package/dist/cli/commands/init.d.ts +8 -0
- package/dist/cli/commands/init.js +81 -0
- package/dist/cli/commands/invoke.d.ts +7 -0
- package/dist/cli/commands/invoke.js +29 -0
- package/dist/cli/commands/login.d.ts +6 -0
- package/dist/cli/commands/login.js +63 -0
- package/dist/cli/commands/models.d.ts +1 -0
- package/dist/cli/commands/models.js +15 -0
- package/dist/cli/commands/schedule.d.ts +12 -0
- package/dist/cli/commands/schedule.js +89 -0
- package/dist/cli/commands/start.d.ts +10 -0
- package/dist/cli/commands/start.js +90 -0
- package/dist/cli/commands/tool.d.ts +1 -0
- package/dist/cli/commands/tool.js +37 -0
- package/dist/cli/fail.d.ts +19 -0
- package/dist/cli/fail.js +32 -0
- package/dist/cli/kernel.d.ts +89 -0
- package/dist/cli/kernel.js +190 -0
- package/dist/cli/program.d.ts +11 -0
- package/dist/cli/program.js +421 -0
- package/dist/cli/serve.d.ts +28 -0
- package/dist/cli/serve.js +90 -0
- package/dist/cli/shared.d.ts +24 -0
- package/dist/cli/shared.js +116 -0
- package/dist/cli-add-feishu.d.ts +8 -0
- package/dist/cli-add-feishu.js +223 -0
- package/dist/cli.js +8 -1284
- package/dist/deploy/container.js +10 -6
- package/dist/deploy/docker/plan.d.ts +45 -0
- package/dist/deploy/docker/plan.js +139 -0
- package/dist/deploy/docker/run.d.ts +40 -0
- package/dist/deploy/docker/run.js +126 -0
- package/dist/deploy/fly/plan.d.ts +1 -1
- package/dist/deploy/fly/plan.js +15 -4
- package/dist/deploy/fly/run.d.ts +7 -4
- package/dist/deploy/fly/run.js +26 -5
- package/dist/deploy/preflight.js +4 -3
- package/dist/deploy/railway/plan.d.ts +1 -1
- package/dist/deploy/railway/plan.js +17 -5
- package/dist/deploy/railway/run.d.ts +6 -3
- package/dist/deploy/railway/run.js +26 -4
- package/dist/deploy/registration-gate.d.ts +20 -0
- package/dist/deploy/registration-gate.js +20 -0
- package/dist/deploy/runner.d.ts +4 -1
- package/dist/deploy/runner.js +1 -0
- package/dist/deploy/secrets.d.ts +10 -9
- package/dist/deploy/secrets.js +15 -14
- package/dist/dev-supervisor.js +2 -1
- package/dist/engines/pi/auth.js +160 -46
- package/dist/engines/pi/chat.js +80 -7
- package/dist/engines/pi/config.d.ts +12 -3
- package/dist/engines/pi/config.js +16 -1
- package/dist/engines/pi/create.d.ts +14 -6
- package/dist/engines/pi/create.js +52 -16
- package/dist/engines/pi/harness.d.ts +16 -1
- package/dist/engines/pi/harness.js +77 -1
- package/dist/engines/pi/invoke.d.ts +1 -1
- package/dist/engines/pi/invoke.js +37 -2
- package/dist/engines/pi/login.js +1 -1
- package/dist/engines/pi/search-tools.d.ts +10 -0
- package/dist/engines/pi/search-tools.js +138 -0
- package/dist/engines/pi/tool-context.d.ts +28 -0
- package/dist/engines/pi/tool-context.js +8 -0
- package/dist/engines/pi/tool.d.ts +32 -1
- package/dist/engines/pi/tool.js +42 -1
- package/dist/engines/pi/workspace.d.ts +4 -1
- package/dist/engines/pi/workspace.js +3 -1
- package/dist/feishu.d.ts +2 -0
- package/dist/feishu.js +2 -0
- package/dist/lark.d.ts +3 -0
- package/dist/lark.js +3 -0
- package/dist/open-url.d.ts +2 -0
- package/dist/open-url.js +6 -0
- package/dist/pi.d.ts +2 -1
- package/dist/scaffold/add-channel.d.ts +9 -5
- package/dist/scaffold/add-channel.js +75 -7
- package/dist/scaffold/templates/fastagent.config.mjs +1 -0
- package/dist/tunnel.d.ts +9 -6
- package/dist/tunnel.js +48 -31
- package/package.json +18 -4
- /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
- /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registrationGate } from "../registration-gate.js";
|
|
1
2
|
/**
|
|
2
3
|
* Whether `railway status --json` shows a linked project: non-empty stdout. Unlinked prints its message
|
|
3
4
|
* to stderr and leaves stdout EMPTY (the exit code is 0 either way, so it can't be the signal). ANY
|
|
@@ -61,10 +62,12 @@ export function parseHasVolume(stdout, mountPath) {
|
|
|
61
62
|
return jsonStrings(stdout).includes(mountPath);
|
|
62
63
|
}
|
|
63
64
|
/**
|
|
64
|
-
* Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)`
|
|
65
|
-
* post-deploy webhook
|
|
65
|
+
* Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
|
|
66
|
+
* `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
|
|
67
|
+
* Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
|
|
68
|
+
* instruction is printed. Every gate is fail-visible.
|
|
66
69
|
*/
|
|
67
|
-
export async function deployRailwayRun(plan, railway, log, registerTelegram) {
|
|
70
|
+
export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu) {
|
|
68
71
|
const gate = (g) => ({ ok: false, gate: g });
|
|
69
72
|
// Every --service below targets plan.name — the name this tool gives BOTH the project and the service
|
|
70
73
|
// (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
|
|
@@ -165,12 +168,31 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram) {
|
|
|
165
168
|
if (!url) {
|
|
166
169
|
return gate("couldn't read a domain from `railway domain` — run `railway domain` manually, then set any webhook");
|
|
167
170
|
}
|
|
171
|
+
// 7. Post-deploy webhook — gate policy is the shared registration-gate kernel (registrars report
|
|
172
|
+
// facts, it owns the policy); all channels are attempted first.
|
|
173
|
+
const reg = registrationGate(log, "re-run with --into-linked to retry registration");
|
|
168
174
|
if (plan.channels.includes("telegram")) {
|
|
169
175
|
log("registering telegram webhook…");
|
|
170
|
-
await registerTelegram(url);
|
|
176
|
+
reg.track("telegram", await registerTelegram(url));
|
|
171
177
|
}
|
|
172
178
|
if (plan.channels.includes("github")) {
|
|
173
179
|
log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
|
|
180
|
+
reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
|
|
181
|
+
}
|
|
182
|
+
for (const kind of ["feishu", "lark"]) {
|
|
183
|
+
if (!plan.channels.includes(kind))
|
|
184
|
+
continue;
|
|
185
|
+
if (registerFeishu) {
|
|
186
|
+
log(`registering ${kind} event URL…`);
|
|
187
|
+
reg.track(kind, await registerFeishu(url, kind));
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
log(`${kind}: set the event Request URL in the developer console (Events & Callbacks) → ${url}/${kind} (the service must be running when you save)`);
|
|
191
|
+
reg.track(kind, "manual"); // no registrar wired — the console step above is the operator's
|
|
192
|
+
}
|
|
174
193
|
}
|
|
194
|
+
const registrationGateMsg = reg.gate();
|
|
195
|
+
if (registrationGateMsg)
|
|
196
|
+
return gate(registrationGateMsg);
|
|
175
197
|
return { ok: true, url };
|
|
176
198
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-NEUTRAL step-7 gate policy, shared by the host runners: the registrars report facts
|
|
3
|
+
* ({@link RegistrationOutcome}), this module owns what to do about them.
|
|
4
|
+
*
|
|
5
|
+
* - "failed" gates — exit 0 would tell a coding agent "done" while the deployed agent cannot receive
|
|
6
|
+
* messages, and the runners' earlier steps are idempotent so a re-run just retries registration.
|
|
7
|
+
* - "manual" does NOT gate — re-running can never change it (an unclearable gate would spin a coding
|
|
8
|
+
* agent forever) — but is re-surfaced after all registrar output (and before any gate message) so
|
|
9
|
+
* the operator cannot miss it under the registrar's earlier log lines.
|
|
10
|
+
*
|
|
11
|
+
* The runners attempt ALL channels first (one failure doesn't skip the rest), then apply this policy
|
|
12
|
+
* once. Only the retry remediation differs per host (`retryHint`).
|
|
13
|
+
*/
|
|
14
|
+
import type { RegistrationOutcome } from "../channels/registration.ts";
|
|
15
|
+
export declare function registrationGate(log: (msg: string) => void, retryHint: string): {
|
|
16
|
+
track: (kind: string, outcome: RegistrationOutcome) => void;
|
|
17
|
+
/** Logs the manual notices; returns the gate message (composes with cli.ts's "deploy stopped:"
|
|
18
|
+
* prefix — it leads with the failure, not "the deploy succeeded"), or undefined for no gate. */
|
|
19
|
+
gate: () => string | undefined;
|
|
20
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function registrationGate(log, retryHint) {
|
|
2
|
+
const unregistered = [];
|
|
3
|
+
const manual = [];
|
|
4
|
+
return {
|
|
5
|
+
track(kind, outcome) {
|
|
6
|
+
if (outcome === "failed")
|
|
7
|
+
unregistered.push(kind);
|
|
8
|
+
if (outcome === "manual")
|
|
9
|
+
manual.push(kind);
|
|
10
|
+
},
|
|
11
|
+
gate() {
|
|
12
|
+
for (const kind of manual) {
|
|
13
|
+
log(`${kind}: webhook registration needs a one-time manual step — see the instructions above`);
|
|
14
|
+
}
|
|
15
|
+
if (unregistered.length === 0)
|
|
16
|
+
return undefined;
|
|
17
|
+
return `webhook registration failed for: ${unregistered.join(", ")} — the app itself deployed; fix the error above, then ${retryHint}`;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/deploy/runner.d.ts
CHANGED
|
@@ -5,10 +5,13 @@ export interface RunResult {
|
|
|
5
5
|
stdout: string;
|
|
6
6
|
}
|
|
7
7
|
/** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
|
|
8
|
-
* terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
|
|
8
|
+
* terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
|
|
9
|
+
* `env` adds child-only environment values — Docker Compose interpolates secrets from it without putting
|
|
10
|
+
* values in argv or mutating the long-lived CLI process. */
|
|
9
11
|
export type CliRunner = (args: string[], opts?: {
|
|
10
12
|
capture?: boolean;
|
|
11
13
|
input?: string;
|
|
14
|
+
env?: NodeJS.ProcessEnv;
|
|
12
15
|
}) => Promise<RunResult>;
|
|
13
16
|
/**
|
|
14
17
|
* Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
|
package/dist/deploy/runner.js
CHANGED
|
@@ -13,6 +13,7 @@ export function spawnRunner(bin, cwd) {
|
|
|
13
13
|
return (args, opts) => new Promise((res) => {
|
|
14
14
|
const child = spawn(bin, args, {
|
|
15
15
|
cwd,
|
|
16
|
+
env: opts?.env ? { ...process.env, ...opts.env } : process.env,
|
|
16
17
|
stdio: [opts?.input ? "pipe" : "inherit", opts?.capture ? "pipe" : "inherit", "inherit"],
|
|
17
18
|
});
|
|
18
19
|
let out = "";
|
package/dist/deploy/secrets.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The secret set a deployed agent needs, computed from the definition — host-neutral.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* The runbooks list these NAMES; `--run` (per host) reads their VALUES from the local env.
|
|
2
|
+
* The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
|
|
3
|
+
* gate every target; optional channel values travel only when configured. Only the SET command differs
|
|
4
|
+
* (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
|
|
6
5
|
*/
|
|
7
6
|
import { type ChannelKind } from "../scaffold/add-channel.ts";
|
|
8
7
|
/**
|
|
@@ -13,13 +12,15 @@ import { type ChannelKind } from "../scaffold/add-channel.ts";
|
|
|
13
12
|
*/
|
|
14
13
|
export declare function isEnvKey(source: string | undefined): source is string;
|
|
15
14
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Secret NAMES + hints for a runbook: the model key (when local auth is an env key), discovered channel
|
|
16
|
+
* secrets, and config extras. Channel metadata keeps optional values visible without presenting them as
|
|
17
|
+
* deployment prerequisites. An OAuth/stored login has no env key here — it carries as
|
|
18
18
|
* `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
|
|
19
19
|
*/
|
|
20
|
-
export declare function
|
|
20
|
+
export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
|
|
21
21
|
name: string;
|
|
22
22
|
hint: string;
|
|
23
|
+
required: boolean;
|
|
23
24
|
}[];
|
|
24
25
|
/**
|
|
25
26
|
* Assemble the secret VALUES a `--run` deploy sets on the host, from the local credential + channels —
|
|
@@ -32,8 +33,8 @@ export declare function requiredSecrets(modelAuth: string | undefined, channels:
|
|
|
32
33
|
*
|
|
33
34
|
* Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
|
|
34
35
|
* human-shared secret (github's webhook secret must match the value the operator enters in the repo,
|
|
35
|
-
* which a silent mint never surfaces) and would rotate every run (breaking idempotency).
|
|
36
|
-
* `missingSecrets
|
|
36
|
+
* which a silent mint never surfaces) and would rotate every run (breaking idempotency). An absent
|
|
37
|
+
* required value enters `missingSecrets`; an absent optional value is simply omitted.
|
|
37
38
|
*/
|
|
38
39
|
export declare function assembleSecrets(input: {
|
|
39
40
|
modelAuth: string | undefined;
|
package/dist/deploy/secrets.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The secret set a deployed agent needs, computed from the definition — host-neutral.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* The runbooks list these NAMES; `--run` (per host) reads their VALUES from the local env.
|
|
2
|
+
* The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
|
|
3
|
+
* gate every target; optional channel values travel only when configured. Only the SET command differs
|
|
4
|
+
* (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
|
|
6
5
|
*/
|
|
7
6
|
import { channelSetup } from "../scaffold/add-channel.js";
|
|
8
7
|
/**
|
|
@@ -15,22 +14,23 @@ export function isEnvKey(source) {
|
|
|
15
14
|
return source !== undefined && /^[A-Z][A-Z0-9_]*$/.test(source);
|
|
16
15
|
}
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Secret NAMES + hints for a runbook: the model key (when local auth is an env key), discovered channel
|
|
18
|
+
* secrets, and config extras. Channel metadata keeps optional values visible without presenting them as
|
|
19
|
+
* deployment prerequisites. An OAuth/stored login has no env key here — it carries as
|
|
20
20
|
* `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
|
|
21
21
|
*/
|
|
22
|
-
export function
|
|
22
|
+
export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
|
|
23
23
|
const secrets = [];
|
|
24
24
|
if (isEnvKey(modelAuth))
|
|
25
|
-
secrets.push({ name: modelAuth, hint: "your model provider key" });
|
|
25
|
+
secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
|
|
26
26
|
for (const kind of channels) {
|
|
27
27
|
for (const e of channelSetup(kind).env)
|
|
28
|
-
secrets.push({ name: e.name, hint: e.hint });
|
|
28
|
+
secrets.push({ name: e.name, hint: e.hint, required: e.required });
|
|
29
29
|
}
|
|
30
30
|
// Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
|
|
31
31
|
for (const name of extraSecrets) {
|
|
32
32
|
if (!secrets.some((s) => s.name === name)) {
|
|
33
|
-
secrets.push({ name, hint: "declared in fastagent.config deploy.secrets" });
|
|
33
|
+
secrets.push({ name, hint: "declared in fastagent.config deploy.secrets", required: true });
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
return secrets;
|
|
@@ -46,8 +46,8 @@ export function requiredSecrets(modelAuth, channels, extraSecrets = []) {
|
|
|
46
46
|
*
|
|
47
47
|
* Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
|
|
48
48
|
* human-shared secret (github's webhook secret must match the value the operator enters in the repo,
|
|
49
|
-
* which a silent mint never surfaces) and would rotate every run (breaking idempotency).
|
|
50
|
-
* `missingSecrets
|
|
49
|
+
* which a silent mint never surfaces) and would rotate every run (breaking idempotency). An absent
|
|
50
|
+
* required value enters `missingSecrets`; an absent optional value is simply omitted.
|
|
51
51
|
*/
|
|
52
52
|
export function assembleSecrets(input) {
|
|
53
53
|
const secrets = {};
|
|
@@ -70,9 +70,10 @@ export function assembleSecrets(input) {
|
|
|
70
70
|
for (const e of channelSetup(kind).env) {
|
|
71
71
|
const v = input.env[e.name];
|
|
72
72
|
if (v)
|
|
73
|
-
secrets[e.name] = v;
|
|
74
|
-
else
|
|
73
|
+
secrets[e.name] = v; // optional channel values travel when configured
|
|
74
|
+
else if (e.required) {
|
|
75
75
|
missingSecrets.push(e.name); // operator-provided (in .env); a human-shared secret can't be minted
|
|
76
|
+
}
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
for (const name of input.extraSecrets ?? []) {
|
package/dist/dev-supervisor.js
CHANGED
|
@@ -16,6 +16,7 @@ import { watch as watchTree } from "chokidar";
|
|
|
16
16
|
import { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
|
|
17
17
|
import { log } from "./log.js";
|
|
18
18
|
import { installProxyFetch } from "./proxy.js";
|
|
19
|
+
import { openExternalUrl } from "./open-url.js";
|
|
19
20
|
import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
|
|
20
21
|
/** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
|
|
21
22
|
export const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
|
|
@@ -99,7 +100,7 @@ export async function runDevSupervisor(dir, options = {}) {
|
|
|
99
100
|
void startCloudflareTunnel(m.port).then((t) => {
|
|
100
101
|
if (t) {
|
|
101
102
|
tunnel = t;
|
|
102
|
-
void announceWebhooks(agentDir, t.url);
|
|
103
|
+
void announceWebhooks(agentDir, t.url, { openUrl: openExternalUrl });
|
|
103
104
|
}
|
|
104
105
|
});
|
|
105
106
|
}
|
package/dist/engines/pi/auth.js
CHANGED
|
@@ -12,22 +12,24 @@
|
|
|
12
12
|
* token and persisting the new one elsewhere leaves global stale for every other consumer.
|
|
13
13
|
*
|
|
14
14
|
* Sharing is still SAFE the right way: point everything at ONE file (`FASTAGENT_AUTH_PATH` → the
|
|
15
|
-
* global path). One file means one refresh lifecycle under
|
|
16
|
-
*
|
|
15
|
+
* global path). One file means one refresh lifecycle under the store's cross-process write lock
|
|
16
|
+
* (refresh re-reads the latest token under the lock), the documented same-machine pattern.
|
|
17
17
|
* fastagent's store stays SEPARATE from the pi CLI's `~/.pi/agent/auth.json` for the same single-
|
|
18
18
|
* lifecycle reason: two uncoordinated files over one grant would each rotate and break the other.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* path
|
|
20
|
+
* Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
|
|
21
|
+
* before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
|
|
22
|
+
* custom pi-ai `CredentialStore`, which this file is). The lock guards the WRITE path only. `read`
|
|
23
|
+
* is pi-ai's per-request hot path, so it stays UNLOCKED; the in-place locked write opens only a
|
|
24
|
+
* sub-millisecond torn-read window, which `read` absorbs by re-reading. The write path refuses to
|
|
25
|
+
* overwrite a corrupt file (never clobbering other providers' credentials).
|
|
24
26
|
*/
|
|
25
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
28
|
import { homedir } from "node:os";
|
|
27
|
-
import { join } from "node:path";
|
|
29
|
+
import { dirname, join } from "node:path";
|
|
28
30
|
import { log } from "../../log.js";
|
|
29
31
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
30
|
-
import
|
|
32
|
+
import lockfile from "proper-lockfile";
|
|
31
33
|
/**
|
|
32
34
|
* The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
|
|
33
35
|
* `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
|
|
@@ -42,57 +44,169 @@ function pick(creds, providerId) {
|
|
|
42
44
|
const cred = creds[providerId];
|
|
43
45
|
return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
|
|
44
46
|
}
|
|
47
|
+
const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
|
|
45
48
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
49
|
+
* Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
|
|
50
|
+
* 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
|
|
51
|
+
* Ensures the file exists first (0700 dir, 0600 file, EXCLUSIVE create: a concurrent first write
|
|
52
|
+
* must never be clobbered by the init) because `proper-lockfile` locks an existing path. A
|
|
53
|
+
* compromised lock aborts before the write rather than clobbering a concurrent writer, and a
|
|
54
|
+
* failed unlock after a successful operation rejects instead of leaving a stale lock silently.
|
|
48
55
|
*/
|
|
49
|
-
function
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
async function withLockedAuthFile(authPath, fn) {
|
|
57
|
+
const dir = dirname(authPath);
|
|
58
|
+
if (!existsSync(dir))
|
|
59
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
if (!existsSync(authPath)) {
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
|
|
63
|
+
chmodSync(authPath, 0o600);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// EEXIST: another process created the file between the existence check and this exclusive
|
|
67
|
+
// create; its content (possibly already-written credentials) must not be clobbered.
|
|
68
|
+
if (error.code !== "EEXIST")
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let compromised;
|
|
73
|
+
const throwIfCompromised = () => {
|
|
74
|
+
if (compromised)
|
|
75
|
+
throw compromised;
|
|
76
|
+
};
|
|
77
|
+
const release = await lockfile.lock(authPath, {
|
|
78
|
+
retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
|
|
79
|
+
stale: 30_000,
|
|
80
|
+
onCompromised: (error) => {
|
|
81
|
+
compromised = error;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
throwIfCompromised();
|
|
87
|
+
const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
|
|
88
|
+
const out = await fn(current);
|
|
89
|
+
throwIfCompromised();
|
|
90
|
+
if (out.next !== undefined) {
|
|
91
|
+
writeFileSync(authPath, out.next, AUTH_FILE_WRITE_OPTIONS);
|
|
92
|
+
chmodSync(authPath, 0o600);
|
|
93
|
+
}
|
|
94
|
+
throwIfCompromised();
|
|
95
|
+
result = out.result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// The primary failure stays the signal; unlock noise must not mask it.
|
|
99
|
+
try {
|
|
100
|
+
await release();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
// Success path: a failed release is a real cleanup failure (the leftover auth.json.lock stalls
|
|
108
|
+
// the next writer for the staleness window with zero diagnostics), so it surfaces instead of
|
|
109
|
+
// resolving a silently degraded operation. A compromise detected after the last in-band check
|
|
110
|
+
// surfaces here too.
|
|
111
|
+
try {
|
|
112
|
+
await release();
|
|
113
|
+
}
|
|
114
|
+
catch (releaseError) {
|
|
115
|
+
if (compromised === undefined)
|
|
116
|
+
throw releaseError;
|
|
117
|
+
}
|
|
118
|
+
throwIfCompromised();
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Decode the credentials JSON, shared by the read and write paths. The root must be a plain
|
|
123
|
+
* non-null, non-array object: `[]`, `null`, and scalar roots pass JSON.parse but break the record
|
|
124
|
+
* semantics (an array root even swallows writes, since JSON.stringify drops string keys on arrays).
|
|
125
|
+
* Structurally invalid = corrupt, exactly like unparsable text.
|
|
126
|
+
*/
|
|
127
|
+
function decodeCreds(raw) {
|
|
128
|
+
let parsed;
|
|
52
129
|
try {
|
|
53
|
-
|
|
130
|
+
parsed = JSON.parse(raw);
|
|
54
131
|
}
|
|
55
132
|
catch {
|
|
56
|
-
|
|
133
|
+
return undefined;
|
|
57
134
|
}
|
|
135
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
136
|
+
return undefined;
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Tolerant UNLOCKED read of the whole credentials file, shared by `read` and `list`. The only race
|
|
141
|
+
* is a sub-millisecond in-place write during an OAuth rotation, which can yield an empty/partial
|
|
142
|
+
* file; re-read a few times before concluding it is corrupt. A missing file reads as undefined
|
|
143
|
+
* silently (normal not-configured); a valid file returns immediately, so the common case costs one
|
|
144
|
+
* read.
|
|
145
|
+
*/
|
|
146
|
+
async function readCreds(authPath, warn) {
|
|
147
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = readFileSync(authPath, "utf8");
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code === "ENOENT")
|
|
154
|
+
return undefined; // missing/deleted
|
|
155
|
+
warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (raw !== "") {
|
|
159
|
+
const creds = decodeCreds(raw);
|
|
160
|
+
if (creds !== undefined)
|
|
161
|
+
return creds;
|
|
162
|
+
// A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
|
|
163
|
+
// root lands here too and is reported as corrupt below.
|
|
164
|
+
}
|
|
165
|
+
if (attempt < 2)
|
|
166
|
+
await sleep(2);
|
|
167
|
+
}
|
|
168
|
+
warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parse the credentials JSON for a WRITE: a corrupt file must THROW, because serializing `{}` over
|
|
173
|
+
* it would wipe every other provider's credentials. The throw aborts the locked write, leaving the
|
|
174
|
+
* file intact.
|
|
175
|
+
*/
|
|
176
|
+
function parseForWrite(raw, where) {
|
|
177
|
+
if (!raw)
|
|
178
|
+
return {};
|
|
179
|
+
const creds = decodeCreds(raw);
|
|
180
|
+
if (creds === undefined) {
|
|
181
|
+
throw new Error(`refusing to overwrite corrupt auth file ${where}: fix or remove it`);
|
|
182
|
+
}
|
|
183
|
+
return creds;
|
|
58
184
|
}
|
|
59
185
|
/** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
|
|
60
186
|
* the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
|
|
61
187
|
export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options = {}) {
|
|
62
188
|
const warn = options.warn ?? ((message) => log.warn(message));
|
|
63
|
-
const backend = new FileAuthStorageBackend(authPath);
|
|
64
189
|
return {
|
|
65
190
|
async read(providerId) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (raw !== "") {
|
|
81
|
-
try {
|
|
82
|
-
return pick(JSON.parse(raw), providerId);
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
// A partial read mid-write parses as garbage — fall through and retry.
|
|
86
|
-
}
|
|
191
|
+
const creds = await readCreds(authPath, warn);
|
|
192
|
+
return creds ? pick(creds, providerId) : undefined;
|
|
193
|
+
},
|
|
194
|
+
async list() {
|
|
195
|
+
// Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
|
|
196
|
+
// with the same validation as `read`, so both surfaces agree on what "configured" means.
|
|
197
|
+
const creds = await readCreds(authPath, warn);
|
|
198
|
+
if (!creds)
|
|
199
|
+
return [];
|
|
200
|
+
const infos = [];
|
|
201
|
+
for (const [providerId, cred] of Object.entries(creds)) {
|
|
202
|
+
if (cred && (cred.type === "oauth" || cred.type === "api_key")) {
|
|
203
|
+
infos.push({ providerId, type: cred.type });
|
|
87
204
|
}
|
|
88
|
-
if (attempt < 2)
|
|
89
|
-
await sleep(2);
|
|
90
205
|
}
|
|
91
|
-
|
|
92
|
-
return undefined;
|
|
206
|
+
return infos;
|
|
93
207
|
},
|
|
94
208
|
modify(providerId, fn) {
|
|
95
|
-
return
|
|
209
|
+
return withLockedAuthFile(authPath, async (current) => {
|
|
96
210
|
const creds = parseForWrite(current, authPath); // corrupt → throw → no clobber
|
|
97
211
|
const next = await fn(pick(creds, providerId));
|
|
98
212
|
if (next === undefined)
|
|
@@ -102,11 +216,11 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
|
|
|
102
216
|
});
|
|
103
217
|
},
|
|
104
218
|
async delete(providerId) {
|
|
105
|
-
// No-op when nothing is stored: do NOT take the lock (which would create the file
|
|
106
|
-
//
|
|
219
|
+
// No-op when nothing is stored: do NOT take the lock (which would create the file) on a
|
|
220
|
+
// machine that never stored this provider.
|
|
107
221
|
if (!existsSync(authPath))
|
|
108
222
|
return;
|
|
109
|
-
await
|
|
223
|
+
await withLockedAuthFile(authPath, async (current) => {
|
|
110
224
|
const creds = parseForWrite(current, authPath);
|
|
111
225
|
if (!(providerId in creds))
|
|
112
226
|
return { result: undefined }; // absent: no write
|
package/dist/engines/pi/chat.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* AGENTS.md up to the repo root and discover skills from pi's own global dirs). So fastagent's
|
|
8
8
|
* assembly is INJECTED into pi's session:
|
|
9
9
|
* - prompt → systemPromptOverride = base + instructions ONLY; pi appends the skill section and env
|
|
10
|
-
* (
|
|
10
|
+
* (cwd) itself (including it here would duplicate it).
|
|
11
11
|
* - skills → skillsOverride (fastagent's skills, for the section + invocation).
|
|
12
12
|
* - tools → default coding tools by NAME (pi rebuilds them cwd-bound for rich rendering) +
|
|
13
13
|
* fastagent's custom tools via pi's customTools path (so they survive /new, /resume, fork).
|
|
@@ -28,7 +28,9 @@ import { loadConfig, resolveAgentDir, resolveModel, resolveModelSpec } from "./c
|
|
|
28
28
|
import { assembleSystemPrompt, piBasePrompt, piDefaultTools, resolveTools } from "./create.js";
|
|
29
29
|
import { createPiModels } from "./models.js";
|
|
30
30
|
import { canonicalPath, loadAgentDefinition } from "./definition.js";
|
|
31
|
-
import { loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
31
|
+
import { isDeferredTool, loadTools, mergeDiscoveredTools } from "./tool.js";
|
|
32
|
+
import { withSearchTool } from "./search-tools.js";
|
|
33
|
+
import { additiveActivation, turnContext } from "./tool-context.js";
|
|
32
34
|
import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
|
|
33
35
|
/**
|
|
34
36
|
* Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
|
|
@@ -38,6 +40,34 @@ import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollision
|
|
|
38
40
|
export async function buildChatRuntime(dir, options = {},
|
|
39
41
|
/** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
|
|
40
42
|
sessionManager) {
|
|
43
|
+
/** The turn's {@link ToolActivation} over pi's AgentSession — the chat counterpart of invoke.ts's
|
|
44
|
+
* harness bridge, so the SAME builtin search_tools serves both paths. Additive; unknown names
|
|
45
|
+
* filtered (`setActiveToolsByName` is authoritative on the session and rebuilds its prompt — our
|
|
46
|
+
* static override keeps the prompt identical to serving). */
|
|
47
|
+
function chatToolActivation(session) {
|
|
48
|
+
// Same serialization as invoke.ts's bridge (there per turn; here per session — chat turns are
|
|
49
|
+
// interactive, so per-session is equivalent): the read-modify-write below is only race-free while
|
|
50
|
+
// nothing awaits between read and write, and pi's session setters happening to be synchronous today
|
|
51
|
+
// is not a contract worth betting parallel tool batches on. Built ONCE per session (createRuntime),
|
|
52
|
+
// so parallel calls actually share the chain.
|
|
53
|
+
let chain = Promise.resolve([]);
|
|
54
|
+
return {
|
|
55
|
+
active: () => session.getActiveToolNames(),
|
|
56
|
+
registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
|
|
57
|
+
activate(names) {
|
|
58
|
+
const run = async () => {
|
|
59
|
+
const current = session.getActiveToolNames();
|
|
60
|
+
const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
|
|
61
|
+
if (added.length > 0)
|
|
62
|
+
session.setActiveToolsByName([...current, ...added]);
|
|
63
|
+
return added;
|
|
64
|
+
};
|
|
65
|
+
const result = chain.then(run, run); // run after the predecessor settles, success or failure
|
|
66
|
+
chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
|
|
67
|
+
return result;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
41
71
|
async function resolveAssembly(cwd) {
|
|
42
72
|
const { config } = await loadConfig(cwd);
|
|
43
73
|
const modelSpec = resolveModelSpec(options.model, config);
|
|
@@ -55,21 +85,42 @@ sessionManager) {
|
|
|
55
85
|
// Same tool resolution as the dev opener, then split: defaults go to pi by NAME (rebuilt cwd-bound
|
|
56
86
|
// for rich rendering); customs go through pi's `customTools` path so they survive /new, /resume, fork.
|
|
57
87
|
const discovered = await loadTools(agentDir);
|
|
58
|
-
const
|
|
88
|
+
const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
|
|
89
|
+
// Chat EMULATES deferral, like serving (what you iterate is what you serve): the builtin loader
|
|
90
|
+
// mounts when a deferred tool exists, the initial active set excludes deferred tools (applied on
|
|
91
|
+
// the session in createRuntime below — pi's TUI session starts all-active), and the activation
|
|
92
|
+
// bridge below rides the same turn context the serving path uses, so the SAME search_tools works
|
|
93
|
+
// against pi's AgentSession instead of fastagent's harness.
|
|
94
|
+
const tools = withSearchTool(merged.tools);
|
|
95
|
+
const crossCollisions = merged.collisions;
|
|
59
96
|
reportToolCollisions([...discovered.collisions, ...crossCollisions]);
|
|
60
97
|
reportModuleLoadFailures(discovered.failures);
|
|
61
98
|
const defaultNames = piDefaultTools(cwd).map((t) => t.name);
|
|
62
99
|
const customTools = tools.filter((t) => !defaultNames.includes(t.name));
|
|
63
|
-
// Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
|
|
100
|
+
// Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
|
|
101
|
+
// it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
|
|
102
|
+
// assembly is memoized across /new//resume/fork rebuilds while the session changes, so the bridge
|
|
103
|
+
// resolves through sessionRef at call time, exactly like the serving path resolves its harness.
|
|
64
104
|
const customToolDefs = customTools.map((t) => ({
|
|
65
105
|
name: t.name,
|
|
66
106
|
label: t.name,
|
|
67
107
|
description: t.description ?? "",
|
|
68
108
|
parameters: t.parameters,
|
|
69
|
-
|
|
109
|
+
// Propagate the execution mode — an activating tool (the builtin loader) declares "sequential"
|
|
110
|
+
// so pi serializes its batch; without this, pi's outer active-set diff double-stamps parallels.
|
|
111
|
+
executionMode: t.executionMode,
|
|
112
|
+
execute: (id, params, signal) => {
|
|
113
|
+
const bound = sessionRef.current;
|
|
114
|
+
// Unreachable by construction (createRuntime sets sessionRef before any turn can run a tool).
|
|
115
|
+
// Throw rather than silently run outside the turn context — that would disguise a broken
|
|
116
|
+
// session-lifecycle invariant as a normal out-of-turn call (fail visibly).
|
|
117
|
+
if (!bound)
|
|
118
|
+
throw new Error("chat tool executed before its session was built (lifecycle invariant broken)");
|
|
119
|
+
return turnContext.run({ session: bound.session.sessionId, tools: bound.activation }, () => t.execute(id, params, signal));
|
|
120
|
+
},
|
|
70
121
|
}));
|
|
71
|
-
// base + instructions ONLY — pi appends the skill section and env (
|
|
72
|
-
// them here would duplicate
|
|
122
|
+
// base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
|
|
123
|
+
// them here would duplicate them).
|
|
73
124
|
const systemPrompt = assembleSystemPrompt({
|
|
74
125
|
base: piBasePrompt({ tools, persona: definition.persona }),
|
|
75
126
|
contextFiles: definition.contextFiles,
|
|
@@ -82,6 +133,13 @@ sessionManager) {
|
|
|
82
133
|
// edits. And keep it workspace-scoped — `.env` is process-global, so a switch to another cwd would
|
|
83
134
|
// leak env or require mutating global env at runtime.
|
|
84
135
|
const rootCwd = canonicalPath(dir);
|
|
136
|
+
// The CURRENT pi session + its activation bridge, BOUND TOGETHER — rebuilt on /new//resume/fork
|
|
137
|
+
// while the memoized assembly (and its tool execute closures) stays. The bridge must share the
|
|
138
|
+
// session's lifetime, NOT be rebuilt per tool call (a per-call chain serializes nothing). Note on
|
|
139
|
+
// parallel batches: pi wraps SDK customTools in its own before/after active-set diff, so an
|
|
140
|
+
// activating tool must carry `executionMode: "sequential"` (the builtin loader does) — pi then runs
|
|
141
|
+
// the whole batch serially and the outer diff sees correct snapshots.
|
|
142
|
+
const sessionRef = {};
|
|
85
143
|
let assembly;
|
|
86
144
|
const assemblyFor = (cwd) => {
|
|
87
145
|
// Canonical paths: pi's process.cwd() fallback is a realpath, so a symlinked workspace would
|
|
@@ -135,6 +193,21 @@ sessionManager) {
|
|
|
135
193
|
tools: [...defaultNames, ...customTools.map((t) => t.name)],
|
|
136
194
|
customTools: customToolDefs,
|
|
137
195
|
});
|
|
196
|
+
sessionRef.current = { session: result.session, activation: chatToolActivation(result.session) };
|
|
197
|
+
// Deferral emulation: pi's TUI session starts with everything active — narrow it by SUBTRACTING
|
|
198
|
+
// the deferred names from whatever is active (robust to pi mounting tools of its own; an
|
|
199
|
+
// exact-set-equality gate would silently stop narrowing the day pi adds one). Applied on EVERY
|
|
200
|
+
// build including /resume: pi's chat session does not record activations (its SessionContext has
|
|
201
|
+
// no activeToolNames), so "restore prior activations" is not implementable here — deferral stays
|
|
202
|
+
// consistently ON and a resumed conversation re-discovers via search_tools (documented divergence
|
|
203
|
+
// from serving, where activations persist in the session).
|
|
204
|
+
const deferredNames = customTools.filter(isDeferredTool).map((t) => t.name);
|
|
205
|
+
if (deferredNames.length > 0) {
|
|
206
|
+
const active = result.session.getActiveToolNames();
|
|
207
|
+
if (deferredNames.some((n) => active.includes(n))) {
|
|
208
|
+
result.session.setActiveToolsByName(active.filter((n) => !deferredNames.includes(n)));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
138
211
|
return { ...result, services, diagnostics: services.diagnostics };
|
|
139
212
|
};
|
|
140
213
|
const runtime = await createAgentSessionRuntime(createRuntime, {
|