@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +122 -5
  2. package/NOTICE.md +26 -0
  3. package/builtin/skills/plugin-authoring/SKILL.md +20 -1
  4. package/dist/cli.js +2424 -1237
  5. package/dist/sdk/accounts/accounts.d.ts +26 -20
  6. package/dist/sdk/accounts/accounts.js +100 -63
  7. package/dist/sdk/agents/builtin.js +2 -0
  8. package/dist/sdk/commands/registry.d.ts +7 -0
  9. package/dist/sdk/commands/registry.js +1 -0
  10. package/dist/sdk/core/commandBudget.js +7 -2
  11. package/dist/sdk/core/workspaceWriteOperations.d.ts +24 -0
  12. package/dist/sdk/core/workspaceWriteOperations.js +21 -0
  13. package/dist/sdk/plugins/builtin.js +0 -4
  14. package/dist/sdk/plugins/discover.js +80 -4
  15. package/dist/sdk/plugins/install.js +26 -0
  16. package/dist/sdk/plugins/manifest.d.ts +2 -0
  17. package/dist/sdk/plugins/manifest.js +20 -1
  18. package/dist/sdk/prompt/compile.d.ts +4 -1
  19. package/dist/sdk/prompt/compile.js +8 -21
  20. package/dist/sdk/prompt/reporting.d.ts +2 -0
  21. package/dist/sdk/prompt/reporting.js +282 -0
  22. package/dist/sdk/prompt/standard.d.ts +12 -0
  23. package/dist/sdk/prompt/standard.js +53 -2
  24. package/dist/sdk/sessions/checkpoint.d.ts +7 -0
  25. package/dist/sdk/sessions/checkpoint.js +41 -0
  26. package/dist/sdk/sessions/compactionCheckpoint.d.ts +2 -0
  27. package/dist/sdk/sessions/compactionCheckpoint.js +2 -1
  28. package/dist/sdk/sessions/completionCheck.d.ts +49 -0
  29. package/dist/sdk/sessions/completionCheck.js +143 -0
  30. package/dist/sdk/sessions/continuation.js +1 -1
  31. package/dist/sdk/sessions/contracts.d.ts +97 -1
  32. package/dist/sdk/sessions/contracts.js +17 -0
  33. package/dist/sdk/sessions/goal.d.ts +145 -0
  34. package/dist/sdk/sessions/goal.js +405 -0
  35. package/dist/sdk/sessions/goalLoop.d.ts +37 -0
  36. package/dist/sdk/sessions/goalLoop.js +125 -0
  37. package/dist/sdk/sessions/goalPrompts.d.ts +4 -0
  38. package/dist/sdk/sessions/goalPrompts.js +93 -0
  39. package/dist/sdk/sessions/planMode.d.ts +31 -0
  40. package/dist/sdk/sessions/planMode.js +48 -3
  41. package/dist/sdk/sessions/registry.d.ts +6 -1
  42. package/dist/sdk/sessions/registry.js +4 -4
  43. package/dist/sdk/sessions/runStore.js +1 -0
  44. package/dist/sdk/sessions/runner.d.ts +9 -0
  45. package/dist/sdk/sessions/runner.js +65 -13
  46. package/dist/sdk/sessions/sessions.d.ts +10 -0
  47. package/dist/sdk/sessions/sessions.js +87 -10
  48. package/dist/sdk/sessions/stopGate.d.ts +78 -0
  49. package/dist/sdk/sessions/stopGate.js +143 -0
  50. package/dist/sdk/sessions/subagentService.d.ts +2 -0
  51. package/dist/sdk/sessions/subagentService.js +1 -1
  52. package/dist/sdk/sessions/workSlice.d.ts +61 -12
  53. package/dist/sdk/sessions/workSlice.js +102 -33
  54. package/dist/sdk/terminal/controller.d.ts +2 -2
  55. package/dist/sdk/terminal/controller.js +14 -4
  56. package/dist/sdk/terminal/credentialStore.d.ts +4 -0
  57. package/dist/sdk/terminal/credentialStore.js +33 -0
  58. package/dist/sdk/terminal/piExtension.d.ts +2 -1
  59. package/dist/sdk/terminal/piExtension.js +160 -27
  60. package/dist/sdk/terminal/terminals.js +27 -4
  61. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  62. package/dist/sdk/terminal/tui/commands.js +3 -0
  63. package/dist/sdk/terminal/tui/goalCommand.d.ts +19 -0
  64. package/dist/sdk/terminal/tui/goalCommand.js +83 -0
  65. package/dist/sdk/terminal/tui/planMode.d.ts +1 -1
  66. package/dist/sdk/terminal/tui/planMode.js +1 -1
  67. package/dist/sdk/terminal/tui/runtime.d.ts +3 -0
  68. package/dist/sdk/terminal/tui/runtime.js +21 -4
  69. package/dist/sdk/tools/askUser.js +1 -1
  70. package/dist/sdk/tools/todo.js +2 -1
  71. package/dist/terminal-opentui.js +1740 -627
  72. package/dist/terminal-pi.js +996 -407
  73. package/docs/architecture/02-sessions-and-runs.md +47 -11
  74. package/docs/architecture/04-prompt.md +83 -16
  75. package/docs/architecture/06-plugins-and-capabilities.md +16 -1
  76. package/docs/architecture/07-tools-and-gate.md +7 -2
  77. package/docs/architecture/09-accounts-and-connectors.md +9 -0
  78. package/docs/architecture/10-server-protocol.md +13 -0
  79. package/docs/architecture/11-terminal.md +38 -12
  80. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +2 -0
  81. package/docs/decisions/ADR-0008-flexible-reporting-guidance.md +3 -0
  82. package/docs/decisions/ADR-0063-unfinished-work-handoffs.md +2 -0
  83. package/docs/decisions/ADR-0068-scope-and-completion-report.md +60 -0
  84. package/docs/decisions/ADR-0069-stop-gate-and-finish.md +130 -0
  85. package/docs/decisions/ADR-0070-terminal-slice-continuation.md +46 -0
  86. package/docs/decisions/ADR-0071-completion-check.md +104 -0
  87. package/docs/decisions/ADR-0072-codex-prompts-and-goal.md +105 -0
  88. package/docs/decisions/ADR-0073-large-reporting-prompt.md +84 -0
  89. package/docs/parity.md +4 -1
  90. package/docs/releases/1.3.1-unstable.9.md +54 -0
  91. package/docs/releases/1.4.0-unstable.md +18 -0
  92. package/docs/releases/README.md +1 -1
  93. package/docs/study/00-index.md +10 -0
  94. package/docs/study/STUDY-0004-turn-end-controls.md +123 -0
  95. package/docs/study/STUDY-0005-skill-installation-guidance.md +146 -0
  96. package/docs/study/STUDY-0006-reporting-prompts.md +81 -0
  97. package/docs/study/arms/04-skills.md +6 -0
  98. package/docs/terminal-tui.md +17 -7
  99. package/package.json +1 -1
  100. package/dist/sdk/tools/checkpoint.d.ts +0 -27
  101. 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 use each account's auth.json. */
50
+ /** How many of this process's terminals run on each account. */
49
51
  private readonly terminals;
50
- /** The credentials this process wrote to, or found in, each account's auth.json when its first terminal began. */
51
- private readonly terminalSnapshots;
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's use of the account's auth.json. The file is written from the store when the
150
- * first terminal of the account begins. While another process's terminal still uses it, the tokens
151
- * already in the file stay, because that terminal may have refreshed them; only a provider the file
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
- * Ends a terminal's use of the auth.json. Each terminal saves what its Pi changed since it began (a
157
- * refreshed or new sign-in, or a provider it signed out of), even while another process still uses the
158
- * file, so no refresh is lost when the other terminal ends first. A credential saved to the store after
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
- terminalEnd(account: Account): Promise<void>;
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, except a terminal session's `auth.json` while that terminal runs.
4
- import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
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 use each account's auth.json. */
88
+ /** How many of this process's terminals run on each account. */
72
89
  terminals = new Map();
73
- /** The credentials this process wrote to, or found in, each account's auth.json when its first terminal began. */
74
- terminalSnapshots = new Map();
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's use of the account's auth.json. The file is written from the store when the
584
- * first terminal of the account begins. While another process's terminal still uses it, the tokens
585
- * already in the file stay, because that terminal may have refreshed them; only a provider the file
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
- const existing = shared && existsSync(path) ? readAuth(path) : null;
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
- let changed = false;
636
- for (const provider of new Set([...Object.keys(snapshot), ...Object.keys(data)])) {
637
- if (provider in data) {
638
- if (sameCredential(data[provider], snapshot[provider]))
639
- continue;
640
- await this.writeCredential(account.id, provider, data[provider]).catch(() => undefined);
641
- changed = true;
642
- }
643
- else if (provider in snapshot) {
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
- if (changed) {
649
- this.invalidate(account.id);
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
- const timeout = Math.floor(requested);
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: `A command may run for at most ${COMMAND_TIMEOUT_MAX_SECONDS} seconds. Split it into smaller commands.` };
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
- for (const configured of manager().listConfiguredPackages())
126
- if (configured.scope === 'user' && configured.installedPath)
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
- for (const folder of packages ?? [])
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
  }