@xingular/harness 1.3.1-unstable.8 → 1.4.0-unstable
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 +122 -5
- package/NOTICE.md +26 -0
- package/builtin/skills/plugin-authoring/SKILL.md +20 -1
- package/dist/cli.js +2424 -1237
- package/dist/sdk/accounts/accounts.d.ts +26 -20
- package/dist/sdk/accounts/accounts.js +100 -63
- package/dist/sdk/agents/builtin.js +2 -0
- package/dist/sdk/commands/registry.d.ts +7 -0
- package/dist/sdk/commands/registry.js +1 -0
- package/dist/sdk/core/commandBudget.js +7 -2
- package/dist/sdk/core/workspaceWriteOperations.d.ts +24 -0
- package/dist/sdk/core/workspaceWriteOperations.js +21 -0
- package/dist/sdk/plugins/builtin.js +0 -4
- package/dist/sdk/plugins/discover.js +80 -4
- package/dist/sdk/plugins/install.js +26 -0
- package/dist/sdk/plugins/manifest.d.ts +2 -0
- package/dist/sdk/plugins/manifest.js +20 -1
- package/dist/sdk/prompt/compile.d.ts +4 -1
- package/dist/sdk/prompt/compile.js +8 -21
- package/dist/sdk/prompt/reporting.d.ts +2 -0
- package/dist/sdk/prompt/reporting.js +282 -0
- package/dist/sdk/prompt/standard.d.ts +12 -0
- package/dist/sdk/prompt/standard.js +53 -2
- package/dist/sdk/sessions/checkpoint.d.ts +7 -0
- package/dist/sdk/sessions/checkpoint.js +41 -0
- package/dist/sdk/sessions/compactionCheckpoint.d.ts +2 -0
- package/dist/sdk/sessions/compactionCheckpoint.js +2 -1
- package/dist/sdk/sessions/completionCheck.d.ts +49 -0
- package/dist/sdk/sessions/completionCheck.js +143 -0
- package/dist/sdk/sessions/continuation.js +1 -1
- package/dist/sdk/sessions/contracts.d.ts +97 -1
- package/dist/sdk/sessions/contracts.js +17 -0
- package/dist/sdk/sessions/goal.d.ts +145 -0
- package/dist/sdk/sessions/goal.js +405 -0
- package/dist/sdk/sessions/goalLoop.d.ts +37 -0
- package/dist/sdk/sessions/goalLoop.js +125 -0
- package/dist/sdk/sessions/goalPrompts.d.ts +4 -0
- package/dist/sdk/sessions/goalPrompts.js +93 -0
- package/dist/sdk/sessions/planMode.d.ts +31 -0
- package/dist/sdk/sessions/planMode.js +48 -3
- package/dist/sdk/sessions/registry.d.ts +6 -1
- package/dist/sdk/sessions/registry.js +4 -4
- package/dist/sdk/sessions/runStore.js +1 -0
- package/dist/sdk/sessions/runner.d.ts +9 -0
- package/dist/sdk/sessions/runner.js +65 -13
- package/dist/sdk/sessions/sessions.d.ts +10 -0
- package/dist/sdk/sessions/sessions.js +87 -10
- package/dist/sdk/sessions/stopGate.d.ts +78 -0
- package/dist/sdk/sessions/stopGate.js +143 -0
- package/dist/sdk/sessions/subagentService.d.ts +2 -0
- package/dist/sdk/sessions/subagentService.js +1 -1
- package/dist/sdk/sessions/workSlice.d.ts +61 -12
- package/dist/sdk/sessions/workSlice.js +102 -33
- package/dist/sdk/terminal/controller.d.ts +2 -2
- package/dist/sdk/terminal/controller.js +14 -4
- package/dist/sdk/terminal/credentialStore.d.ts +4 -0
- package/dist/sdk/terminal/credentialStore.js +33 -0
- package/dist/sdk/terminal/piExtension.d.ts +2 -1
- package/dist/sdk/terminal/piExtension.js +160 -27
- package/dist/sdk/terminal/terminals.js +27 -4
- package/dist/sdk/terminal/tui/commands.d.ts +2 -0
- package/dist/sdk/terminal/tui/commands.js +3 -0
- package/dist/sdk/terminal/tui/goalCommand.d.ts +19 -0
- package/dist/sdk/terminal/tui/goalCommand.js +83 -0
- package/dist/sdk/terminal/tui/planMode.d.ts +1 -1
- package/dist/sdk/terminal/tui/planMode.js +1 -1
- package/dist/sdk/terminal/tui/runtime.d.ts +3 -0
- package/dist/sdk/terminal/tui/runtime.js +21 -4
- package/dist/sdk/tools/askUser.js +1 -1
- package/dist/sdk/tools/todo.js +2 -1
- package/dist/terminal-opentui.js +1740 -627
- package/dist/terminal-pi.js +996 -407
- package/docs/architecture/02-sessions-and-runs.md +47 -11
- package/docs/architecture/04-prompt.md +83 -16
- package/docs/architecture/06-plugins-and-capabilities.md +16 -1
- package/docs/architecture/07-tools-and-gate.md +7 -2
- package/docs/architecture/09-accounts-and-connectors.md +9 -0
- package/docs/architecture/10-server-protocol.md +13 -0
- package/docs/architecture/11-terminal.md +38 -12
- package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +2 -0
- package/docs/decisions/ADR-0008-flexible-reporting-guidance.md +3 -0
- package/docs/decisions/ADR-0063-unfinished-work-handoffs.md +2 -0
- package/docs/decisions/ADR-0068-scope-and-completion-report.md +60 -0
- package/docs/decisions/ADR-0069-stop-gate-and-finish.md +130 -0
- package/docs/decisions/ADR-0070-terminal-slice-continuation.md +46 -0
- package/docs/decisions/ADR-0071-completion-check.md +104 -0
- package/docs/decisions/ADR-0072-codex-prompts-and-goal.md +105 -0
- package/docs/decisions/ADR-0073-large-reporting-prompt.md +84 -0
- package/docs/parity.md +4 -1
- package/docs/releases/1.3.1-unstable.9.md +54 -0
- package/docs/releases/1.4.0-unstable.md +18 -0
- package/docs/releases/README.md +1 -1
- package/docs/study/00-index.md +10 -0
- package/docs/study/STUDY-0004-turn-end-controls.md +123 -0
- package/docs/study/STUDY-0005-skill-installation-guidance.md +146 -0
- package/docs/study/STUDY-0006-reporting-prompts.md +81 -0
- package/docs/study/arms/04-skills.md +6 -0
- package/docs/terminal-tui.md +17 -7
- package/package.json +1 -1
- package/dist/sdk/tools/checkpoint.d.ts +0 -27
- package/dist/sdk/tools/checkpoint.js +0 -154
|
@@ -18,6 +18,8 @@ export interface ModelOption {
|
|
|
18
18
|
type Credential = Record<string, unknown> & {
|
|
19
19
|
type: 'api_key' | 'oauth';
|
|
20
20
|
};
|
|
21
|
+
/** How long a terminal may hold a provider's credential lock; shorter than another waiter's lock timeout. */
|
|
22
|
+
export declare const CREDENTIAL_LEASE_MS = 20000;
|
|
21
23
|
export interface AccountLoginSnapshot {
|
|
22
24
|
accountId: string;
|
|
23
25
|
loginId: string;
|
|
@@ -45,10 +47,10 @@ export declare class Accounts {
|
|
|
45
47
|
private readonly runtimes;
|
|
46
48
|
private readonly logins;
|
|
47
49
|
private readonly latestLogins;
|
|
48
|
-
/** How many of this process's terminals
|
|
50
|
+
/** How many of this process's terminals run on each account. */
|
|
49
51
|
private readonly terminals;
|
|
50
|
-
/**
|
|
51
|
-
private readonly
|
|
52
|
+
/** Open credential leases: a terminal holds a provider's lock while its Pi refreshes or signs in. */
|
|
53
|
+
private readonly leases;
|
|
52
54
|
private installationDeviceIdPromise;
|
|
53
55
|
/**
|
|
54
56
|
* Lets the limits service observe each account runtime's provider responses. The Harness sets
|
|
@@ -79,6 +81,18 @@ export declare class Accounts {
|
|
|
79
81
|
}[]>;
|
|
80
82
|
/** Pi and explicit account edits share the same cross-process credential lock. */
|
|
81
83
|
private credentialStore;
|
|
84
|
+
/**
|
|
85
|
+
* Takes a provider's cross-process credential lock for a terminal whose Pi will refresh or sign in
|
|
86
|
+
* with the current credential, and holds it until `commitCredentialLease` or `CREDENTIAL_LEASE_MS`.
|
|
87
|
+
* A terminal reads and writes credentials only this way, so one refresh token has one lock: two
|
|
88
|
+
* copies refreshed under separate locks made the second refresh fail with `invalid_grant`.
|
|
89
|
+
*/
|
|
90
|
+
leaseCredential(accountId: string, provider: string, timeoutMs?: number): Promise<{
|
|
91
|
+
leaseId: string;
|
|
92
|
+
current: Credential | undefined;
|
|
93
|
+
}>;
|
|
94
|
+
/** Ends a lease, storing `credential` when one is given. Resolves with the stored credential afterwards. */
|
|
95
|
+
commitCredentialLease(accountId: string, leaseId: string, credential?: Credential): Promise<Credential | undefined>;
|
|
82
96
|
/** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
|
|
83
97
|
installationDeviceId(): Promise<string>;
|
|
84
98
|
private loadInstallationDeviceId;
|
|
@@ -138,30 +152,22 @@ export declare class Accounts {
|
|
|
138
152
|
loginStatus(accountId: string): AccountLoginSnapshot | null;
|
|
139
153
|
loginRespond(loginId: string, promptId: string, value: string | null): void;
|
|
140
154
|
loginCancel(accountId: string, loginId?: string | null): void;
|
|
141
|
-
/**
|
|
142
|
-
* The `pi` command reads `auth.json` from its agent folder. For a terminal session the
|
|
143
|
-
* account's stored credentials are written there (mode 0600) while the terminal runs, then
|
|
144
|
-
* read back and removed.
|
|
145
|
-
*/
|
|
146
155
|
/** How many terminal sessions are running on this account right now. */
|
|
147
156
|
terminalCount(accountId: string): number;
|
|
148
157
|
/**
|
|
149
|
-
* Starts a terminal
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* lacks is added from the store, so a key saved after the other terminal began still reaches this one.
|
|
158
|
+
* Starts a terminal on the account. Its Pi reads and writes credentials through this store over
|
|
159
|
+
* the terminal's control socket (`accounts.credentials.*`), so nothing is written to `auth.json`.
|
|
160
|
+
* A file an older terminal left is folded into the store, newest token first.
|
|
153
161
|
*/
|
|
154
162
|
terminalBegin(account: Account): Promise<void>;
|
|
163
|
+
/** Ends a terminal on the account; the last one in this process folds in any `auth.json` left behind. */
|
|
164
|
+
terminalEnd(account: Account): Promise<void>;
|
|
155
165
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* the terminal began is in neither the file nor the snapshot, so it stays. The last terminal of the
|
|
160
|
-
* account to end, in every process, removes the file.
|
|
166
|
+
* Saves what an older terminal's `auth.json` holds that the store lacks or holds older, under each
|
|
167
|
+
* provider's lock, and never replaces a newer token. The file is removed once no other process has
|
|
168
|
+
* a terminal on the account, since an older one may still be reading it.
|
|
161
169
|
*/
|
|
162
|
-
|
|
163
|
-
/** The account's stored credentials, with `existing` kept as it is for every provider it already has. */
|
|
164
|
-
private storedCredentials;
|
|
170
|
+
private adoptAuthFile;
|
|
165
171
|
/**
|
|
166
172
|
* Whether another live process has a terminal on this account. Lease files of processes that have
|
|
167
173
|
* ended are removed, so a crash never leaves the account looking busy.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The accounts service: everything that needs the secret store, the Pi runtime, or a notice.
|
|
2
2
|
// Credentials go to the embedding application's secret store; nothing here writes one to the
|
|
3
|
-
// harness's own files
|
|
4
|
-
import {
|
|
3
|
+
// harness's own files. A terminal's Pi reaches them through `accounts.credentials.*`.
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { log } from "../core/bus.js";
|
|
@@ -55,6 +55,23 @@ function sameCredential(a, b) {
|
|
|
55
55
|
return false;
|
|
56
56
|
return JSON.stringify(a, Object.keys(a).sort()) === JSON.stringify(b, Object.keys(b).sort());
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether a credential found outside the store should replace the stored one. The store is the
|
|
60
|
+
* truth; an OAuth token wins only when it expires later, because OpenAI and others rotate the
|
|
61
|
+
* refresh token on every refresh and an older one is already spent.
|
|
62
|
+
*/
|
|
63
|
+
function preferCredential(candidate, stored) {
|
|
64
|
+
if (!stored)
|
|
65
|
+
return true;
|
|
66
|
+
if (sameCredential(candidate, stored))
|
|
67
|
+
return false;
|
|
68
|
+
if (candidate.type !== 'oauth' || stored.type !== 'oauth')
|
|
69
|
+
return false;
|
|
70
|
+
const expires = (credential) => typeof credential.expires === 'number' ? credential.expires : 0;
|
|
71
|
+
return expires(candidate) > expires(stored);
|
|
72
|
+
}
|
|
73
|
+
/** How long a terminal may hold a provider's credential lock; shorter than another waiter's lock timeout. */
|
|
74
|
+
export const CREDENTIAL_LEASE_MS = 20_000;
|
|
58
75
|
function validProvider(provider) {
|
|
59
76
|
if (provider && provider.length <= 64 && /^[A-Za-z0-9._-]+$/.test(provider))
|
|
60
77
|
return provider;
|
|
@@ -68,10 +85,10 @@ export class Accounts {
|
|
|
68
85
|
runtimes = new Map();
|
|
69
86
|
logins = new Map();
|
|
70
87
|
latestLogins = new Map();
|
|
71
|
-
/** How many of this process's terminals
|
|
88
|
+
/** How many of this process's terminals run on each account. */
|
|
72
89
|
terminals = new Map();
|
|
73
|
-
/**
|
|
74
|
-
|
|
90
|
+
/** Open credential leases: a terminal holds a provider's lock while its Pi refreshes or signs in. */
|
|
91
|
+
leases = new Map();
|
|
75
92
|
installationDeviceIdPromise = null;
|
|
76
93
|
/**
|
|
77
94
|
* Lets the limits service observe each account runtime's provider responses. The Harness sets
|
|
@@ -178,6 +195,51 @@ export class Accounts {
|
|
|
178
195
|
delete: (providerId) => this.writeCredential(accountId, providerId, null)
|
|
179
196
|
};
|
|
180
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Takes a provider's cross-process credential lock for a terminal whose Pi will refresh or sign in
|
|
200
|
+
* with the current credential, and holds it until `commitCredentialLease` or `CREDENTIAL_LEASE_MS`.
|
|
201
|
+
* A terminal reads and writes credentials only this way, so one refresh token has one lock: two
|
|
202
|
+
* copies refreshed under separate locks made the second refresh fail with `invalid_grant`.
|
|
203
|
+
*/
|
|
204
|
+
async leaseCredential(accountId, provider, timeoutMs = CREDENTIAL_LEASE_MS) {
|
|
205
|
+
this.find(accountId);
|
|
206
|
+
const name = validProvider(provider);
|
|
207
|
+
const leaseId = randomUUID();
|
|
208
|
+
let grant;
|
|
209
|
+
const granted = new Promise((resolve) => { grant = resolve; });
|
|
210
|
+
let commit;
|
|
211
|
+
const committed = new Promise((resolve) => { commit = resolve; });
|
|
212
|
+
const done = this.modifyCredential(accountId, name, async (current) => {
|
|
213
|
+
grant(current);
|
|
214
|
+
// An abandoned lease changes nothing.
|
|
215
|
+
const timer = setTimeout(() => commit(undefined), timeoutMs);
|
|
216
|
+
timer.unref?.();
|
|
217
|
+
try {
|
|
218
|
+
return await committed;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
}
|
|
223
|
+
}).finally(() => this.leases.delete(leaseId));
|
|
224
|
+
this.leases.set(leaseId, { accountId, commit, done });
|
|
225
|
+
// The lock can fail (a wait timeout) before it is granted; once granted, how it ends is the commit's concern.
|
|
226
|
+
const ended = done.then(() => { throw new Error('The credential lease ended before it was granted.'); });
|
|
227
|
+
ended.catch(() => undefined);
|
|
228
|
+
const current = await Promise.race([granted, ended]);
|
|
229
|
+
return { leaseId, current };
|
|
230
|
+
}
|
|
231
|
+
/** Ends a lease, storing `credential` when one is given. Resolves with the stored credential afterwards. */
|
|
232
|
+
async commitCredentialLease(accountId, leaseId, credential) {
|
|
233
|
+
const lease = this.leases.get(leaseId);
|
|
234
|
+
if (!lease || lease.accountId !== accountId)
|
|
235
|
+
throw new HarnessError('not_found', 'That credential lease has ended.');
|
|
236
|
+
if (credential !== undefined && (!credential || typeof credential !== 'object' || (credential.type !== 'oauth' && credential.type !== 'api_key'))) {
|
|
237
|
+
lease.commit(undefined);
|
|
238
|
+
throw invalid('Invalid credential.');
|
|
239
|
+
}
|
|
240
|
+
lease.commit(credential);
|
|
241
|
+
return lease.done;
|
|
242
|
+
}
|
|
181
243
|
/** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
|
|
182
244
|
installationDeviceId() {
|
|
183
245
|
if (!this.installationDeviceIdPromise) {
|
|
@@ -570,55 +632,31 @@ export class Accounts {
|
|
|
570
632
|
store.audit(this.dir, accountId, 'login.cancelled', '');
|
|
571
633
|
}
|
|
572
634
|
// ---- Terminal sessions --------------------------------------------------------------------
|
|
573
|
-
/**
|
|
574
|
-
* The `pi` command reads `auth.json` from its agent folder. For a terminal session the
|
|
575
|
-
* account's stored credentials are written there (mode 0600) while the terminal runs, then
|
|
576
|
-
* read back and removed.
|
|
577
|
-
*/
|
|
578
635
|
/** How many terminal sessions are running on this account right now. */
|
|
579
636
|
terminalCount(accountId) {
|
|
580
637
|
return this.terminals.get(accountId) ?? 0;
|
|
581
638
|
}
|
|
582
639
|
/**
|
|
583
|
-
* Starts a terminal
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* lacks is added from the store, so a key saved after the other terminal began still reaches this one.
|
|
640
|
+
* Starts a terminal on the account. Its Pi reads and writes credentials through this store over
|
|
641
|
+
* the terminal's control socket (`accounts.credentials.*`), so nothing is written to `auth.json`.
|
|
642
|
+
* A file an older terminal left is folded into the store, newest token first.
|
|
587
643
|
*/
|
|
588
644
|
async terminalBegin(account) {
|
|
589
645
|
const count = (this.terminals.get(account.id) ?? 0) + 1;
|
|
590
646
|
this.terminals.set(account.id, count);
|
|
591
647
|
if (count > 1)
|
|
592
648
|
return;
|
|
593
|
-
const path = join(env.configDir(this.dir, account), 'auth.json');
|
|
594
|
-
let shared = false;
|
|
595
649
|
try {
|
|
596
|
-
shared = this.otherTerminalProcesses(account.id);
|
|
597
650
|
this.claimLease(account.id);
|
|
598
|
-
|
|
599
|
-
const data = await this.storedCredentials(account.id, existing ?? {});
|
|
600
|
-
if (!existing || Object.keys(data).length > Object.keys(existing).length) {
|
|
601
|
-
atomicWrite(path, JSON.stringify(data, null, 2));
|
|
602
|
-
chmodSync(path, 0o600);
|
|
603
|
-
}
|
|
604
|
-
this.terminalSnapshots.set(account.id, data);
|
|
651
|
+
await this.adoptAuthFile(account);
|
|
605
652
|
}
|
|
606
653
|
catch (error) {
|
|
607
654
|
this.terminals.delete(account.id);
|
|
608
|
-
this.terminalSnapshots.delete(account.id);
|
|
609
655
|
this.releaseLease(account.id);
|
|
610
|
-
if (!shared)
|
|
611
|
-
rmSync(path, { force: true });
|
|
612
656
|
throw error;
|
|
613
657
|
}
|
|
614
658
|
}
|
|
615
|
-
/**
|
|
616
|
-
* Ends a terminal's use of the auth.json. Each terminal saves what its Pi changed since it began (a
|
|
617
|
-
* refreshed or new sign-in, or a provider it signed out of), even while another process still uses the
|
|
618
|
-
* file, so no refresh is lost when the other terminal ends first. A credential saved to the store after
|
|
619
|
-
* the terminal began is in neither the file nor the snapshot, so it stays. The last terminal of the
|
|
620
|
-
* account to end, in every process, removes the file.
|
|
621
|
-
*/
|
|
659
|
+
/** Ends a terminal on the account; the last one in this process folds in any `auth.json` left behind. */
|
|
622
660
|
async terminalEnd(account) {
|
|
623
661
|
const remaining = Math.max(0, (this.terminals.get(account.id) ?? 1) - 1);
|
|
624
662
|
if (remaining > 0) {
|
|
@@ -626,45 +664,44 @@ export class Accounts {
|
|
|
626
664
|
return;
|
|
627
665
|
}
|
|
628
666
|
this.terminals.delete(account.id);
|
|
629
|
-
const snapshot = this.terminalSnapshots.get(account.id) ?? {};
|
|
630
|
-
this.terminalSnapshots.delete(account.id);
|
|
631
667
|
this.releaseLease(account.id);
|
|
668
|
+
await this.adoptAuthFile(account);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Saves what an older terminal's `auth.json` holds that the store lacks or holds older, under each
|
|
672
|
+
* provider's lock, and never replaces a newer token. The file is removed once no other process has
|
|
673
|
+
* a terminal on the account, since an older one may still be reading it.
|
|
674
|
+
*/
|
|
675
|
+
async adoptAuthFile(account) {
|
|
632
676
|
const path = join(env.configDir(this.dir, account), 'auth.json');
|
|
677
|
+
if (!existsSync(path))
|
|
678
|
+
return;
|
|
633
679
|
const data = readAuth(path);
|
|
634
|
-
if (data)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
await this.writeCredential(account.id, provider, null).catch(() => undefined);
|
|
680
|
+
if (!data)
|
|
681
|
+
throw new Error(`The terminal credential file ${path} is not readable.`);
|
|
682
|
+
let changed = false;
|
|
683
|
+
for (const [provider, candidate] of Object.entries(data)) {
|
|
684
|
+
if (!candidate || typeof candidate !== 'object' || (candidate.type !== 'oauth' && candidate.type !== 'api_key'))
|
|
685
|
+
continue;
|
|
686
|
+
try {
|
|
687
|
+
await this.modifyCredential(account.id, provider, async (stored) => {
|
|
688
|
+
if (!preferCredential(candidate, stored))
|
|
689
|
+
return undefined;
|
|
645
690
|
changed = true;
|
|
646
|
-
|
|
691
|
+
return candidate;
|
|
692
|
+
});
|
|
647
693
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
store.audit(this.dir, account.id, 'credentials.synced', 'Pi terminal sign-in saved to the Keychain.');
|
|
694
|
+
catch (error) {
|
|
695
|
+
log('terminal credentials', `${provider}: ${error instanceof Error ? error.message : String(error)}`);
|
|
651
696
|
}
|
|
652
697
|
}
|
|
698
|
+
if (changed) {
|
|
699
|
+
this.invalidate(account.id);
|
|
700
|
+
store.audit(this.dir, account.id, 'credentials.synced', 'A terminal sign-in from auth.json was saved to the Keychain.');
|
|
701
|
+
}
|
|
653
702
|
if (!this.otherTerminalProcesses(account.id))
|
|
654
703
|
rmSync(path, { force: true });
|
|
655
704
|
}
|
|
656
|
-
/** The account's stored credentials, with `existing` kept as it is for every provider it already has. */
|
|
657
|
-
async storedCredentials(accountId, existing) {
|
|
658
|
-
const data = { ...existing };
|
|
659
|
-
for (const provider of store.loadState(this.dir, accountId).piProviders) {
|
|
660
|
-
if (provider in data)
|
|
661
|
-
continue;
|
|
662
|
-
const credential = await this.readCredential(accountId, provider).catch(() => null);
|
|
663
|
-
if (credential)
|
|
664
|
-
data[provider] = credential;
|
|
665
|
-
}
|
|
666
|
-
return data;
|
|
667
|
-
}
|
|
668
705
|
/**
|
|
669
706
|
* Whether another live process has a terminal on this account. Lease files of processes that have
|
|
670
707
|
* ended are removed, so a crash never leaves the account looking busy.
|
|
@@ -27,6 +27,7 @@ export const BUILTIN_PROFILES = [
|
|
|
27
27
|
'- Search broadly first, then read the parts that matter.',
|
|
28
28
|
'- Keep a todo list with todo_write when the search has several parts, and name your session with session_name.',
|
|
29
29
|
'- Cite what you found as path:line.',
|
|
30
|
+
'- If the brief needs a tool you do not have (running tests or commands, editing), say so in your first line, then answer what you can by reading.',
|
|
30
31
|
'- Return conclusions, not file dumps: what you found, where, and how sure you are.'
|
|
31
32
|
].join('\n'),
|
|
32
33
|
tools: ['read', 'grep', 'find', 'ls', ...PROGRESS, ...RECALL, ...WEB],
|
|
@@ -40,6 +41,7 @@ export const BUILTIN_PROFILES = [
|
|
|
40
41
|
'You investigate the bounded research question in your brief and return concise, evidence-backed findings to the parent.',
|
|
41
42
|
'- Use read-only repository and public-web sources. Never modify files, run commands, call connected apps, or ask the user.',
|
|
42
43
|
'- Prefer primary sources. Cite repository claims as path:line and external claims with source URLs.',
|
|
44
|
+
'- If the brief needs a tool you do not have (running tests or commands, editing), say so in your first line, then answer what you can by reading.',
|
|
43
45
|
'- Separate observed facts from inference, state uncertainty, and return only findings relevant to the brief; do not implement.'
|
|
44
46
|
].join('\n'),
|
|
45
47
|
tools: ['read', 'grep', 'find', 'ls', ...PROGRESS, ...RECALL, ...WEB],
|
|
@@ -30,6 +30,13 @@ export declare const CORE_COMMANDS: readonly [{
|
|
|
30
30
|
readonly group: "Run";
|
|
31
31
|
readonly kind: "session";
|
|
32
32
|
readonly methods: readonly ["sessions.run"];
|
|
33
|
+
}, {
|
|
34
|
+
readonly name: "goal";
|
|
35
|
+
readonly args: "[<objective>|edit|pause|resume|clear]";
|
|
36
|
+
readonly description: "Set or view a goal for a long-running task: /goal <objective>";
|
|
37
|
+
readonly group: "Run";
|
|
38
|
+
readonly kind: "session";
|
|
39
|
+
readonly methods: readonly ["sessions.run"];
|
|
33
40
|
}, {
|
|
34
41
|
readonly name: "btw";
|
|
35
42
|
readonly aliases: readonly ["side"];
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export const CORE_COMMANDS = [
|
|
5
5
|
{ name: 'help', description: 'List the commands', group: 'Core', kind: 'local' },
|
|
6
6
|
{ name: 'plan', args: '[brief]', description: 'Plan one request, read-only: /plan <brief>', group: 'Run', kind: 'session', methods: ['sessions.run'] },
|
|
7
|
+
{ name: 'goal', args: '[<objective>|edit|pause|resume|clear]', description: 'Set or view a goal for a long-running task: /goal <objective>', group: 'Run', kind: 'session', methods: ['sessions.run'] },
|
|
7
8
|
{ name: 'btw', aliases: ['side'], args: '[question]', description: 'Ask a side question without interrupting: /btw <question>', group: 'Run', kind: 'session', methods: ['sideQuestions.start', 'sideQuestions.cancel'] },
|
|
8
9
|
{ name: 'new', description: 'Start a new session', group: 'Session', kind: 'session' },
|
|
9
10
|
{ name: 'stop', description: 'Stop the current reply', group: 'Run', kind: 'session', methods: ['runs.interrupt'] },
|
|
@@ -12,11 +12,16 @@ export function resolveCommandTimeout(requested, reason) {
|
|
|
12
12
|
const why = typeof reason === 'string' ? reason.trim() : '';
|
|
13
13
|
if (typeof requested !== 'number' || !Number.isFinite(requested))
|
|
14
14
|
return { ok: true, timeout: DEFAULT_COMMAND_TIMEOUT_SECONDS };
|
|
15
|
-
|
|
15
|
+
let timeout = Math.floor(requested);
|
|
16
16
|
if (timeout < 1)
|
|
17
17
|
return { ok: false, message: 'timeout must be a positive number of seconds.' };
|
|
18
|
+
// Some models send milliseconds (Claude Code's own bash tool takes them; GPT 6 Luna sends 120000).
|
|
19
|
+
// A value that only fits as milliseconds is read as milliseconds, instead of refusing every
|
|
20
|
+
// command that model sends.
|
|
21
|
+
if (timeout > COMMAND_TIMEOUT_MAX_SECONDS && timeout >= 1_000 && timeout / 1_000 <= COMMAND_TIMEOUT_MAX_SECONDS)
|
|
22
|
+
timeout = Math.ceil(timeout / 1_000);
|
|
18
23
|
if (timeout > COMMAND_TIMEOUT_MAX_SECONDS)
|
|
19
|
-
return { ok: false, message: `
|
|
24
|
+
return { ok: false, message: `timeout is in seconds, and a command may run for at most ${COMMAND_TIMEOUT_MAX_SECONDS} seconds. Split it into smaller commands.` };
|
|
20
25
|
if (timeout > COMMAND_TIMEOUT_CEILING_SECONDS && !why) {
|
|
21
26
|
return { ok: false, message: `A command over ${COMMAND_TIMEOUT_CEILING_SECONDS} seconds needs a reason: pass reason with why it cannot be narrowed or run in smaller steps.` };
|
|
22
27
|
}
|
|
@@ -30,4 +30,28 @@ export declare const workspaceEditOperations: {
|
|
|
30
30
|
};
|
|
31
31
|
/** Binds one policy-approved path and abort signal to Pi's native write/edit operation. */
|
|
32
32
|
export declare function withWorkspaceWriteAuthorization<T>(tool: T, authorizations: Map<string, WorkspaceWriteAuthorization>): T;
|
|
33
|
+
interface PromptMetadata {
|
|
34
|
+
promptSnippet?: string;
|
|
35
|
+
promptGuidelines?: string[];
|
|
36
|
+
}
|
|
37
|
+
/** The part of Pi's tool factories the workspace file tools use. Structural, so this module needs no Pi import. */
|
|
38
|
+
export interface FileToolFactories<W, E> {
|
|
39
|
+
createWriteTool(cwd: string, options: {
|
|
40
|
+
operations: typeof workspaceWriteOperations;
|
|
41
|
+
}): W;
|
|
42
|
+
createWriteToolDefinition(cwd: string, options: {
|
|
43
|
+
operations: typeof workspaceWriteOperations;
|
|
44
|
+
}): PromptMetadata;
|
|
45
|
+
createEditTool(cwd: string, options: {
|
|
46
|
+
operations: typeof workspaceEditOperations;
|
|
47
|
+
}): E;
|
|
48
|
+
createEditToolDefinition(cwd: string, options: {
|
|
49
|
+
operations: typeof workspaceEditOperations;
|
|
50
|
+
}): PromptMetadata;
|
|
51
|
+
}
|
|
52
|
+
/** Pi's native `write` and `edit`, routed through the workspace operations and per-call authorization. */
|
|
53
|
+
export declare function createWorkspaceFileTools<W, E>(pi: FileToolFactories<W, E>, cwd: string, authorizations: Map<string, WorkspaceWriteAuthorization>): {
|
|
54
|
+
write: W;
|
|
55
|
+
edit: E;
|
|
56
|
+
};
|
|
33
57
|
export {};
|
|
@@ -222,3 +222,24 @@ export function withWorkspaceWriteAuthorization(tool, authorizations) {
|
|
|
222
222
|
}
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Pi's `createWriteTool` and `createEditTool` drop the definition's prompt line and guidelines, and Pi
|
|
227
|
+
* lists only tools with a prompt line in the system prompt. Without them the model is told it has
|
|
228
|
+
* only `read` and `bash`, and edits files through shell scripts. Pi's `createBashTool` restores them
|
|
229
|
+
* the same way.
|
|
230
|
+
*/
|
|
231
|
+
function withPromptMetadata(tool, definition) {
|
|
232
|
+
return Object.assign(tool, {
|
|
233
|
+
promptSnippet: definition.promptSnippet,
|
|
234
|
+
promptGuidelines: definition.promptGuidelines
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Pi's native `write` and `edit`, routed through the workspace operations and per-call authorization. */
|
|
238
|
+
export function createWorkspaceFileTools(pi, cwd, authorizations) {
|
|
239
|
+
const writeOptions = { operations: workspaceWriteOperations };
|
|
240
|
+
const editOptions = { operations: workspaceEditOperations };
|
|
241
|
+
return {
|
|
242
|
+
write: withWorkspaceWriteAuthorization(withPromptMetadata(pi.createWriteTool(cwd, writeOptions), pi.createWriteToolDefinition(cwd, writeOptions)), authorizations),
|
|
243
|
+
edit: withWorkspaceWriteAuthorization(withPromptMetadata(pi.createEditTool(cwd, editOptions), pi.createEditToolDefinition(cwd, editOptions)), authorizations)
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
import { CONNECTION_TOOLS, NATIVE_GROUP } from "../prompt/compile.js";
|
|
3
3
|
import { ASK_USER, askUserTool } from "../tools/askUser.js";
|
|
4
4
|
import { TODO_WRITE, todoTool } from "../tools/todo.js";
|
|
5
|
-
import { CHECKPOINT_WRITE, checkpointTool } from "../tools/checkpoint.js";
|
|
6
|
-
import { sliceOf } from "../sessions/workSlice.js";
|
|
7
5
|
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from "../sessions/subagents.js";
|
|
8
6
|
import { subagentTools } from "../tools/spawnSubagent.js";
|
|
9
7
|
import { SESSION_NAME } from "../tools/sessionName.js";
|
|
@@ -81,7 +79,6 @@ const WIKI_PLUGIN = {
|
|
|
81
79
|
const HISTORY_PLUGIN = { id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]), toolGroups: ['History'] };
|
|
82
80
|
const ASK_USER_PLUGIN = { id: 'ask-user', title: 'Ask the user', description: 'Lets the agent stop and ask a question with choices.', required: 'An agent must be able to stop and ask you.', tools: [{ name: ASK_USER }] };
|
|
83
81
|
const TODO_PLUGIN = { id: 'todo', title: 'Todo', description: 'Lets the agent keep a todo list of the steps of its work, recorded with the run so progress can be shown and picked up again.', required: 'Progress of longer work is kept through it.', tools: [{ name: TODO_WRITE, description: 'Write the todo list' }] };
|
|
84
|
-
const CHECKPOINT_PLUGIN = { id: 'checkpoint', title: 'Checkpoint', description: 'Lets a bounded reply record a handoff at a work-slice boundary so the next slice starts from it.', required: 'A reply that stops at its budget leaves the state the next one resumes from.', tools: [{ name: CHECKPOINT_WRITE, description: 'Write the handoff checkpoint' }] };
|
|
85
82
|
const CONNECTED_APPS_PLUGIN = {
|
|
86
83
|
id: 'connected-apps',
|
|
87
84
|
title: 'Connected apps',
|
|
@@ -133,7 +130,6 @@ export const BUILTIN_PLUGIN_REGISTRY = createBuiltinPluginRegistry([
|
|
|
133
130
|
},
|
|
134
131
|
{ descriptor: ASK_USER_PLUGIN, toolOrder: 40, tools: context => context.harnessTools && context.allowedTools.includes(ASK_USER) ? [askUserTool(context.run)] : [] },
|
|
135
132
|
{ descriptor: TODO_PLUGIN, toolOrder: 50, tools: context => context.harnessTools ? [todoTool(context.run)] : [] },
|
|
136
|
-
{ descriptor: CHECKPOINT_PLUGIN, toolOrder: 51, tools: context => context.harnessTools && context.allowedTools.includes(CHECKPOINT_WRITE) ? [checkpointTool(context.run, () => sliceOf(context.env.root, context.runId))] : [] },
|
|
137
133
|
{
|
|
138
134
|
descriptor: CONNECTED_APPS_PLUGIN,
|
|
139
135
|
toolOrder: 0,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// rules. Resolving reads folders and manifests; it never runs an extension.
|
|
3
3
|
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { join, sep } from 'node:path';
|
|
5
|
+
import { basename, join, sep } from 'node:path';
|
|
6
6
|
import { log } from "../core/bus.js";
|
|
7
7
|
import * as frontmatter from "../core/frontmatter.js";
|
|
8
8
|
import { loadPi } from "../pi/runtime.js";
|
|
@@ -46,6 +46,50 @@ export function packageProfiles(root, folder) {
|
|
|
46
46
|
return profiles;
|
|
47
47
|
}
|
|
48
48
|
const inside = (path, parent) => path === parent || path.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`);
|
|
49
|
+
/** At most this many skills are read from one package's foreign manifest. */
|
|
50
|
+
const MAX_MANIFEST_SKILLS = 100;
|
|
51
|
+
/**
|
|
52
|
+
* The skills a package's foreign manifest declares: a folder with a `SKILL.md`, a folder of skill
|
|
53
|
+
* folders, or a `SKILL.md` file. Paths are already relative and inside the package.
|
|
54
|
+
*/
|
|
55
|
+
function declaredSkillFiles(root, declared) {
|
|
56
|
+
const found = new Set();
|
|
57
|
+
const visit = (path, depth) => {
|
|
58
|
+
if (found.size >= MAX_MANIFEST_SKILLS)
|
|
59
|
+
return;
|
|
60
|
+
let stat;
|
|
61
|
+
try {
|
|
62
|
+
stat = lstatSync(path);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (stat.isSymbolicLink())
|
|
68
|
+
return;
|
|
69
|
+
if (stat.isFile()) {
|
|
70
|
+
if (basename(path) === 'SKILL.md')
|
|
71
|
+
found.add(path);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!stat.isDirectory() || depth > 2)
|
|
75
|
+
return;
|
|
76
|
+
const own = join(path, 'SKILL.md');
|
|
77
|
+
try {
|
|
78
|
+
if (lstatSync(own).isFile()) {
|
|
79
|
+
found.add(own);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// A folder without its own SKILL.md lists the skills inside it.
|
|
85
|
+
}
|
|
86
|
+
for (const name of readdirSync(path).sort())
|
|
87
|
+
visit(join(path, name), depth + 1);
|
|
88
|
+
};
|
|
89
|
+
for (const path of declared)
|
|
90
|
+
visit(join(root, path), 0);
|
|
91
|
+
return [...found];
|
|
92
|
+
}
|
|
49
93
|
function origin(path, metadata) {
|
|
50
94
|
if (path.startsWith(BUILTIN_PREFIX))
|
|
51
95
|
return 'builtin';
|
|
@@ -122,11 +166,24 @@ export const piResolver = async ({ cwd, agentDir, install, packages }) => {
|
|
|
122
166
|
// A package may name a hooks file for the harness. Only a package a person installed counts:
|
|
123
167
|
// a working folder's package cannot put code into the harness's own process.
|
|
124
168
|
const roots = new Map();
|
|
125
|
-
|
|
126
|
-
|
|
169
|
+
// A foreign manifest's skills are text, so a project's package may bring them too.
|
|
170
|
+
const skillRoots = new Map();
|
|
171
|
+
const filtered = new Set();
|
|
172
|
+
for (const entry of [...(settings.getGlobalSettings().packages ?? []), ...(settings.getProjectSettings().packages ?? [])]) {
|
|
173
|
+
if (entry && typeof entry === 'object' && Array.isArray(entry.skills))
|
|
174
|
+
filtered.add(entry.source);
|
|
175
|
+
}
|
|
176
|
+
for (const configured of manager().listConfiguredPackages()) {
|
|
177
|
+
if (!configured.installedPath)
|
|
178
|
+
continue;
|
|
179
|
+
if (configured.scope === 'user')
|
|
127
180
|
roots.set(configured.installedPath, configured.source);
|
|
128
|
-
|
|
181
|
+
skillRoots.set(configured.installedPath, { source: configured.source, project: configured.scope === 'project' });
|
|
182
|
+
}
|
|
183
|
+
for (const folder of packages ?? []) {
|
|
129
184
|
roots.set(folder, folder);
|
|
185
|
+
skillRoots.set(folder, { source: folder, project: false });
|
|
186
|
+
}
|
|
130
187
|
for (const [root, source] of roots) {
|
|
131
188
|
const manifest = packageManifest(root);
|
|
132
189
|
for (const path of packageProfiles(root, manifest.agents))
|
|
@@ -141,6 +198,25 @@ export const piResolver = async ({ cwd, agentDir, install, packages }) => {
|
|
|
141
198
|
if (fallback !== -1)
|
|
142
199
|
found.splice(fallback, 1);
|
|
143
200
|
}
|
|
201
|
+
// Skills a foreign manifest declares load with the package, under its own switch. A package
|
|
202
|
+
// whose settings entry names skill patterns has expressed a filter, so nothing is added there.
|
|
203
|
+
for (const [root, meta] of skillRoots) {
|
|
204
|
+
if (filtered.has(meta.source))
|
|
205
|
+
continue;
|
|
206
|
+
let added = false;
|
|
207
|
+
for (const path of declaredSkillFiles(root, packageManifest(root).skills)) {
|
|
208
|
+
if (found.some((item) => item.kind === 'skill' && item.path === path))
|
|
209
|
+
continue;
|
|
210
|
+
found.push({ kind: 'skill', path, origin: 'package', project: meta.project, packageRoot: root, source: meta.source, enabled: true });
|
|
211
|
+
added = true;
|
|
212
|
+
}
|
|
213
|
+
if (!added)
|
|
214
|
+
continue;
|
|
215
|
+
// The package has resources of its own, so it is not itself an extension.
|
|
216
|
+
const fallback = found.findIndex((item) => item.kind === 'extension' && item.path === root);
|
|
217
|
+
if (fallback !== -1)
|
|
218
|
+
found.splice(fallback, 1);
|
|
219
|
+
}
|
|
144
220
|
// Pi reads these itself, whatever else is switched off. They are text, so they are only listed.
|
|
145
221
|
const context = [
|
|
146
222
|
...pi.loadProjectContextFiles({ cwd, agentDir }).map((file) => file.path),
|
|
@@ -12,11 +12,37 @@ import { loadPi } from "../pi/runtime.js";
|
|
|
12
12
|
import { ensureAdopted, list } from "./api.js";
|
|
13
13
|
import * as attachments from "./attachments.js";
|
|
14
14
|
import { withoutInstallScripts } from "./discover.js";
|
|
15
|
+
/**
|
|
16
|
+
* A code-hosting page for a folder or file inside a repository. Pi installs a repository, so a
|
|
17
|
+
* pasted `tree` or `blob` address must not reach the package manager as one.
|
|
18
|
+
*/
|
|
19
|
+
function repositoryPage(source) {
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(source);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
28
|
+
return null;
|
|
29
|
+
const host = url.hostname.replace(/^www\./, '');
|
|
30
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
31
|
+
const repository = () => `${url.protocol}//${host}/${parts[0]}/${parts[1]}`;
|
|
32
|
+
if (host === 'github.com' && parts.length >= 4 && (parts[2] === 'tree' || parts[2] === 'blob'))
|
|
33
|
+
return repository();
|
|
34
|
+
if (host === 'gitlab.com' && parts.length >= 5 && parts[2] === '-' && (parts[3] === 'tree' || parts[3] === 'blob'))
|
|
35
|
+
return repository();
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
15
38
|
/** A folder on this machine, or a package as Pi's settings name it. A bare name is an npm package. */
|
|
16
39
|
export function normalizeSource(input) {
|
|
17
40
|
const source = input.trim();
|
|
18
41
|
if (!source)
|
|
19
42
|
throw invalid('Name a package: a folder, an npm name, or a git address.');
|
|
43
|
+
const page = repositoryPage(source);
|
|
44
|
+
if (page)
|
|
45
|
+
throw invalid(`That address is a page inside a repository, not a package. Install ${page} as a package, or copy its skill folder into <project>/.agents/skills/ for one project or ~/.agents/skills/ for every project.`);
|
|
20
46
|
if (source.startsWith('~/'))
|
|
21
47
|
return join(process.env.HOME || homedir(), source.slice(2));
|
|
22
48
|
if (isAbsolute(source))
|
|
@@ -13,6 +13,8 @@ export interface PackageManifest {
|
|
|
13
13
|
hooks: string | null;
|
|
14
14
|
/** The folder of subagent profiles, inside the package. `agents` unless the manifest names another. */
|
|
15
15
|
agents: string;
|
|
16
|
+
/** Skill folders a foreign manifest declares, relative to the package root. */
|
|
17
|
+
skills: string[];
|
|
16
18
|
/** Data-only remote connector forms. Invalid templates are ignored; none can carry credentials. */
|
|
17
19
|
connectorTemplates: ConnectorTemplate[];
|
|
18
20
|
}
|