@xingular/harness 1.1.1 → 1.2.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.
Files changed (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +28 -25
  3. package/dist/cli.js +6675 -2501
  4. package/dist/sdk/accounts/accounts.d.ts +27 -3
  5. package/dist/sdk/accounts/accounts.js +78 -24
  6. package/dist/sdk/accounts/secrets.d.ts +12 -0
  7. package/dist/sdk/accounts/secrets.js +114 -7
  8. package/dist/sdk/accounts/store.d.ts +26 -1
  9. package/dist/sdk/accounts/store.js +1 -1
  10. package/dist/sdk/agents/builtin.js +15 -1
  11. package/dist/sdk/agents/planResearch.d.ts +5 -0
  12. package/dist/sdk/agents/planResearch.js +19 -0
  13. package/dist/sdk/agents/profiles.d.ts +2 -1
  14. package/dist/sdk/agents/profiles.js +9 -4
  15. package/dist/sdk/annotations/pageScript.js +349 -48
  16. package/dist/sdk/annotations/part.js +34 -23
  17. package/dist/sdk/annotations/store.d.ts +7 -0
  18. package/dist/sdk/annotations/store.js +13 -5
  19. package/dist/sdk/browser/backend.d.ts +16 -0
  20. package/dist/sdk/browser/backend.js +17 -0
  21. package/dist/sdk/browser/contracts.d.ts +19 -0
  22. package/dist/sdk/browser/contracts.js +116 -0
  23. package/dist/sdk/browser/tools.d.ts +1 -4
  24. package/dist/sdk/browser/tools.js +14 -91
  25. package/dist/sdk/cli.js +5 -4
  26. package/dist/sdk/commands/list.d.ts +3 -0
  27. package/dist/sdk/commands/list.js +75 -0
  28. package/dist/sdk/commands/registry.d.ts +250 -0
  29. package/dist/sdk/commands/registry.js +47 -0
  30. package/dist/sdk/commands/routes.d.ts +16 -0
  31. package/dist/sdk/commands/routes.js +33 -0
  32. package/dist/sdk/core/commandBudget.d.ts +23 -0
  33. package/dist/sdk/core/commandBudget.js +57 -0
  34. package/dist/sdk/core/index.js +17 -1
  35. package/dist/sdk/core/interactiveProfile.d.ts +46 -0
  36. package/dist/sdk/core/interactiveProfile.js +143 -0
  37. package/dist/sdk/core/workspace.d.ts +13 -0
  38. package/dist/sdk/core/workspace.js +88 -0
  39. package/dist/sdk/core/workspaceBashTool.d.ts +4 -0
  40. package/dist/sdk/core/workspaceBashTool.js +36 -0
  41. package/dist/sdk/core/workspaceSandbox.d.ts +31 -0
  42. package/dist/sdk/core/workspaceSandbox.js +320 -0
  43. package/dist/sdk/core/workspaceWriteOperations.d.ts +22 -0
  44. package/dist/sdk/core/workspaceWriteOperations.js +206 -0
  45. package/dist/sdk/doctor/types.d.ts +2 -2
  46. package/dist/sdk/harness.d.ts +7 -0
  47. package/dist/sdk/harness.js +42 -2
  48. package/dist/sdk/index.d.ts +6 -1
  49. package/dist/sdk/index.js +3 -0
  50. package/dist/sdk/pi/runtime.d.ts +2 -1
  51. package/dist/sdk/pi/runtime.js +2 -2
  52. package/dist/sdk/plugins/builtin.js +29 -12
  53. package/dist/sdk/plugins/builtinRegistry.d.ts +10 -3
  54. package/dist/sdk/plugins/builtinRegistry.js +30 -8
  55. package/dist/sdk/plugins/catalogue.js +2 -1
  56. package/dist/sdk/plugins/commandPrefix.d.ts +8 -0
  57. package/dist/sdk/plugins/commandPrefix.js +21 -0
  58. package/dist/sdk/plugins/firstParty.d.ts +1 -1
  59. package/dist/sdk/plugins/firstParty.js +8 -2
  60. package/dist/sdk/plugins/inventory.js +4 -3
  61. package/dist/sdk/plugins/manifest.d.ts +2 -0
  62. package/dist/sdk/plugins/manifest.js +2 -1
  63. package/dist/sdk/plugins/surfaces.d.ts +5 -0
  64. package/dist/sdk/plugins/surfaces.js +11 -0
  65. package/dist/sdk/plugins/types.d.ts +15 -0
  66. package/dist/sdk/prompt/compile.d.ts +14 -0
  67. package/dist/sdk/prompt/compile.js +65 -29
  68. package/dist/sdk/prompt/standard.d.ts +17 -0
  69. package/dist/sdk/prompt/standard.js +21 -0
  70. package/dist/sdk/server/methods.js +18 -0
  71. package/dist/sdk/server/remote.js +36 -11
  72. package/dist/sdk/sessions/compactionCheckpoint.d.ts +11 -0
  73. package/dist/sdk/sessions/compactionCheckpoint.js +79 -0
  74. package/dist/sdk/sessions/continuation.d.ts +3 -0
  75. package/dist/sdk/sessions/continuation.js +5 -1
  76. package/dist/sdk/sessions/contracts.d.ts +168 -0
  77. package/dist/sdk/sessions/contracts.js +59 -1
  78. package/dist/sdk/sessions/control.d.ts +72 -0
  79. package/dist/sdk/sessions/control.js +158 -0
  80. package/dist/sdk/sessions/initialBash.d.ts +7 -0
  81. package/dist/sdk/sessions/initialBash.js +7 -0
  82. package/dist/sdk/sessions/planMode.d.ts +14 -3
  83. package/dist/sdk/sessions/planMode.js +73 -16
  84. package/dist/sdk/sessions/registry.d.ts +1 -1
  85. package/dist/sdk/sessions/registry.js +2 -2
  86. package/dist/sdk/sessions/runStore.d.ts +14 -0
  87. package/dist/sdk/sessions/runStore.js +35 -0
  88. package/dist/sdk/sessions/runner.d.ts +40 -2
  89. package/dist/sdk/sessions/runner.js +161 -13
  90. package/dist/sdk/sessions/sessions.d.ts +23 -3
  91. package/dist/sdk/sessions/sessions.js +202 -17
  92. package/dist/sdk/sessions/subagentService.d.ts +9 -0
  93. package/dist/sdk/sessions/subagentService.js +63 -4
  94. package/dist/sdk/sessions/workSlice.d.ts +103 -0
  95. package/dist/sdk/sessions/workSlice.js +297 -0
  96. package/dist/sdk/sessions/workspaceBoundary.d.ts +15 -0
  97. package/dist/sdk/sessions/workspaceBoundary.js +46 -0
  98. package/dist/sdk/terminal/browserControl.js +2 -1
  99. package/dist/sdk/terminal/controller.d.ts +11 -3
  100. package/dist/sdk/terminal/controller.js +40 -13
  101. package/dist/sdk/terminal/opentui/theme.d.ts +43 -0
  102. package/dist/sdk/terminal/opentui/theme.js +87 -0
  103. package/dist/sdk/terminal/piExtension.d.ts +6 -1
  104. package/dist/sdk/terminal/piExtension.js +154 -54
  105. package/dist/sdk/terminal/standalone.d.ts +7 -0
  106. package/dist/sdk/terminal/standalone.js +72 -13
  107. package/dist/sdk/terminal/subagentMonitor.d.ts +11 -2
  108. package/dist/sdk/terminal/subagentMonitor.js +32 -1
  109. package/dist/sdk/terminal/terminals.d.ts +19 -2
  110. package/dist/sdk/terminal/terminals.js +245 -35
  111. package/dist/sdk/terminal/tui/annotateCommand.d.ts +1 -1
  112. package/dist/sdk/terminal/tui/annotateCommand.js +3 -3
  113. package/dist/sdk/terminal/tui/commandPages.d.ts +3 -1
  114. package/dist/sdk/terminal/tui/commandPages.js +3 -0
  115. package/dist/sdk/terminal/tui/commands.d.ts +2 -0
  116. package/dist/sdk/terminal/tui/commands.js +205 -124
  117. package/dist/sdk/terminal/tui/list.d.ts +14 -1
  118. package/dist/sdk/terminal/tui/list.js +96 -14
  119. package/dist/sdk/terminal/tui/pageOverlay.js +13 -4
  120. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +1 -1
  121. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +5 -5
  122. package/dist/sdk/terminal/tui/pages/notesPage.js +18 -18
  123. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +0 -5
  124. package/dist/sdk/terminal/tui/pages/pluginsPage.js +9 -3
  125. package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +1 -0
  126. package/dist/sdk/terminal/tui/pages/subagentsPage.js +98 -16
  127. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +43 -0
  128. package/dist/sdk/terminal/tui/pages/usagePage.js +370 -0
  129. package/dist/sdk/terminal/tui/piAdapter.d.ts +1 -2
  130. package/dist/sdk/terminal/tui/piAdapter.js +3 -5
  131. package/dist/sdk/terminal/tui/pluginCommands.d.ts +2 -0
  132. package/dist/sdk/terminal/tui/pluginCommands.js +13 -9
  133. package/dist/sdk/terminal/tui/runtime.js +23 -3
  134. package/dist/sdk/terminal/tui/sessionPicker.d.ts +40 -0
  135. package/dist/sdk/terminal/tui/sessionPicker.js +141 -0
  136. package/dist/sdk/tools/checklist.js +1 -0
  137. package/dist/sdk/tools/checkpoint.d.ts +27 -0
  138. package/dist/sdk/tools/checkpoint.js +154 -0
  139. package/dist/sdk/tools/getCapabilities.d.ts +31 -0
  140. package/dist/sdk/tools/getCapabilities.js +34 -0
  141. package/dist/sdk/tools/spawnSubagent.js +4 -3
  142. package/dist/sdk/usage/analytics.d.ts +20 -0
  143. package/dist/sdk/usage/analytics.js +151 -0
  144. package/dist/sdk/usage/ledger.d.ts +38 -0
  145. package/dist/sdk/usage/ledger.js +142 -0
  146. package/dist/sdk/usage/limits/adapters.d.ts +11 -0
  147. package/dist/sdk/usage/limits/adapters.js +349 -0
  148. package/dist/sdk/usage/limits/capture.d.ts +8 -0
  149. package/dist/sdk/usage/limits/capture.js +47 -0
  150. package/dist/sdk/usage/limits/http.d.ts +12 -0
  151. package/dist/sdk/usage/limits/http.js +51 -0
  152. package/dist/sdk/usage/limits/normalize.d.ts +15 -0
  153. package/dist/sdk/usage/limits/normalize.js +211 -0
  154. package/dist/sdk/usage/limits/service.d.ts +30 -0
  155. package/dist/sdk/usage/limits/service.js +176 -0
  156. package/dist/sdk/usage/limits/types.d.ts +65 -0
  157. package/dist/sdk/usage/limits/types.js +1 -0
  158. package/dist/sdk/usage/serverMethods.d.ts +3 -0
  159. package/dist/sdk/usage/serverMethods.js +22 -0
  160. package/dist/sdk/usage/service.d.ts +32 -0
  161. package/dist/sdk/usage/service.js +66 -0
  162. package/dist/sdk/usage/settings.d.ts +7 -0
  163. package/dist/sdk/usage/settings.js +28 -0
  164. package/dist/sdk/usage/types.d.ts +60 -0
  165. package/dist/sdk/usage/types.js +4 -0
  166. package/dist/terminal-opentui.js +5207 -1883
  167. package/dist/terminal-pi.js +1658 -271
  168. package/docs/architecture/02-sessions-and-runs.md +128 -26
  169. package/docs/architecture/04-prompt.md +50 -12
  170. package/docs/architecture/06-plugins-and-capabilities.md +24 -7
  171. package/docs/architecture/07-tools-and-gate.md +104 -24
  172. package/docs/architecture/08-memory-and-history.md +4 -2
  173. package/docs/architecture/09-accounts-and-connectors.md +31 -1
  174. package/docs/architecture/10-server-protocol.md +58 -3
  175. package/docs/architecture/11-terminal.md +105 -42
  176. package/docs/architecture/12-storage.md +30 -2
  177. package/docs/architecture/13-world-tools.md +22 -1
  178. package/docs/architecture/14-surfaces-and-parity.md +54 -7
  179. package/docs/architecture/README.md +3 -1
  180. package/docs/architecture/proposals/PROP-0017-page-annotations.md +6 -2
  181. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +1 -1
  182. package/docs/architecture/proposals/PROP-0031-plugin-command-owner-namespaces.md +54 -0
  183. package/docs/architecture/proposals/PROP-0032-planning-quality.md +196 -0
  184. package/docs/architecture/proposals/PROP-0033-compaction-plan-checkpoints.md +75 -0
  185. package/docs/architecture/proposals/PROP-0034-conversation-fast-mode.md +120 -0
  186. package/docs/architecture/proposals/PROP-0035-retain-subagent-status.md +47 -0
  187. package/docs/architecture/proposals/PROP-0036-first-class-harness-commands.md +59 -0
  188. package/docs/architecture/proposals/PROP-0037-workspace-write-boundary.md +63 -0
  189. package/docs/architecture/proposals/PROP-0038-annotate-open-shadow-roots.md +73 -0
  190. package/docs/architecture/proposals/PROP-0039-workspace-boundary-hardening.md +139 -0
  191. package/docs/architecture/proposals/PROP-0040-one-launch-path.md +87 -0
  192. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +247 -0
  193. package/docs/architecture/proposals/PROP-0042-usage-and-provider-limits.md +127 -0
  194. package/docs/architecture/proposals/PROP-0043-read-only-plan-bash.md +74 -0
  195. package/docs/architecture/proposals/README.md +14 -0
  196. package/docs/decisions/ADR-0001-owned-terminal.md +1 -1
  197. package/docs/decisions/ADR-0021-required-command-namespaces.md +1 -1
  198. package/docs/decisions/ADR-0031-plugin-command-owner-namespaces.md +37 -0
  199. package/docs/decisions/ADR-0032-compaction-run-checkpoints.md +33 -0
  200. package/docs/decisions/ADR-0033-plan-mode-research-delegation.md +47 -0
  201. package/docs/decisions/ADR-0034-first-class-harness-terminal-commands.md +25 -0
  202. package/docs/decisions/ADR-0035-one-launch-path.md +40 -0
  203. package/docs/decisions/ADR-0036-workspace-write-operation-boundary.md +54 -0
  204. package/docs/decisions/ADR-0037-usage-ledger-and-provider-limits.md +44 -0
  205. package/docs/decisions/ADR-0038-plan-bash-os-read-only.md +29 -0
  206. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime-replication.json +420 -0
  207. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime.json +420 -0
  208. package/docs/experiments/EXP-0003-performance-audit/data/terminal-compact.json +376 -0
  209. package/docs/experiments/EXP-0003-performance-audit/data/terminal-history.json +422 -0
  210. package/docs/experiments/EXP-0003-performance-audit/data/terminal-question.json +95 -0
  211. package/docs/experiments/EXP-0003-performance-audit/data/terminal-wide.json +1036 -0
  212. package/docs/experiments/EXP-0003-performance-audit/plan.md +109 -0
  213. package/docs/experiments/EXP-0003-performance-audit/results.md +151 -0
  214. package/docs/handoff-2026-10-25-app-parity-and-solo-tabs.md +202 -0
  215. package/docs/parity.md +80 -24
  216. package/docs/releases/1.1.1.md +28 -0
  217. package/docs/releases/1.2.0.md +49 -0
  218. package/docs/releases/4.0.0.md +10 -9
  219. package/docs/releases/README.md +35 -30
  220. package/docs/study/00-index.md +3 -0
  221. package/docs/study/STUDY-0003-long-horizon-runs.md +210 -0
  222. package/docs/terminal-tui.md +100 -59
  223. package/package.json +4 -3
@@ -3,8 +3,8 @@ import { type Bus } from "../core/bus.js";
3
3
  import type { RunEvent, RunRecord } from "../sessions/contracts.js";
4
4
  import * as choices from "./choices.js";
5
5
  import * as env from "./env.js";
6
- import { type SecretStore } from "./secrets.js";
7
- import type { Account, AccountsOverview } from "./store.js";
6
+ import { type CredentialLockOptions, type SecretStore } from "./secrets.js";
7
+ import type { Account, AccountsOverview, Limit } from "./store.js";
8
8
  export type IssueCategory = 'auth_expired' | 'rate_limited' | 'quota' | 'network' | 'unknown';
9
9
  /** Best-effort classification of a provider error message. */
10
10
  export declare function classifyMessage(message: string): IssueCategory | null;
@@ -35,11 +35,13 @@ export interface AccountsOptions {
35
35
  dir: string;
36
36
  secrets: SecretStore;
37
37
  bus: Bus;
38
+ credentialLock?: CredentialLockOptions;
38
39
  }
39
40
  export declare class Accounts {
40
41
  readonly dir: string;
41
42
  private readonly secrets;
42
43
  private readonly bus;
44
+ private readonly credentialLockOptions;
43
45
  private readonly runtimes;
44
46
  private readonly logins;
45
47
  private readonly latestLogins;
@@ -48,6 +50,11 @@ export declare class Accounts {
48
50
  /** The credentials this process wrote to, or found in, each account's auth.json when its first terminal began. */
49
51
  private readonly terminalSnapshots;
50
52
  private installationDeviceIdPromise;
53
+ /**
54
+ * Lets the limits service observe each account runtime's provider responses. The Harness sets
55
+ * it once; with none, a runtime behaves exactly as before.
56
+ */
57
+ observeModels: ((models: unknown, accountId: string) => void) | null;
51
58
  constructor(options: AccountsOptions);
52
59
  private changed;
53
60
  overview(): AccountsOverview;
@@ -60,13 +67,17 @@ export declare class Accounts {
60
67
  resolve(accountId?: string | null, poolId?: string | null): Account;
61
68
  context(account: Account): env.AccountContext;
62
69
  readCredential(accountId: string, provider: string): Promise<Credential | null>;
70
+ private credentialLock;
63
71
  /** Stores a provider's credential, or removes it when `credential` is `null`. */
64
72
  writeCredential(accountId: string, provider: string, credential: Credential | null): Promise<void>;
73
+ /** The entire refresh read/modify/write is coordinated across services and processes. */
74
+ modifyCredential(accountId: string, provider: string, change: (current: Credential | undefined) => Promise<Credential | undefined>): Promise<Credential | undefined>;
75
+ private persistCredential;
65
76
  listCredentials(accountId: string): Promise<{
66
77
  providerId: string;
67
78
  type: 'api_key' | 'oauth';
68
79
  }[]>;
69
- /** Pi's `CredentialStore` for one account. Writes are serialized, so a refresh never races another. */
80
+ /** Pi and explicit account edits share the same cross-process credential lock. */
70
81
  private credentialStore;
71
82
  /** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
72
83
  installationDeviceId(): Promise<string>;
@@ -160,6 +171,19 @@ export declare class Accounts {
160
171
  private releaseLease;
161
172
  /** Tracks account health and limits from run events. `run` is the event's run record, when it is known. */
162
173
  onRunEvent(event: RunEvent, run: RunRecord | null): void;
174
+ /**
175
+ * Records what a provider reported about its limits. An endpoint reading replaces that provider's
176
+ * limits; a header reading merges by id. Windows whose reset has passed are dropped. No credential
177
+ * is stored here.
178
+ */
179
+ applyLimits(accountId: string, readouts: readonly {
180
+ provider: string;
181
+ limits: Limit[];
182
+ source: 'headers' | 'provider' | 'estimate' | 'unavailable';
183
+ reason: string | null;
184
+ observedAt: string;
185
+ plan?: string | null;
186
+ }[]): AccountsOverview;
163
187
  private applyStatus;
164
188
  }
165
189
  export {};
@@ -10,7 +10,7 @@ import { atomicWrite, now, readJson, safeId } from "../core/fs.js";
10
10
  import { loadPi } from "../pi/runtime.js";
11
11
  import * as choices from "./choices.js";
12
12
  import * as env from "./env.js";
13
- import { secretKey } from "./secrets.js";
13
+ import { secretKey, withCredentialLock } from "./secrets.js";
14
14
  import * as store from "./store.js";
15
15
  /** Best-effort classification of a provider error message. */
16
16
  export function classifyMessage(message) {
@@ -64,6 +64,7 @@ export class Accounts {
64
64
  dir;
65
65
  secrets;
66
66
  bus;
67
+ credentialLockOptions;
67
68
  runtimes = new Map();
68
69
  logins = new Map();
69
70
  latestLogins = new Map();
@@ -72,10 +73,16 @@ export class Accounts {
72
73
  /** The credentials this process wrote to, or found in, each account's auth.json when its first terminal began. */
73
74
  terminalSnapshots = new Map();
74
75
  installationDeviceIdPromise = null;
76
+ /**
77
+ * Lets the limits service observe each account runtime's provider responses. The Harness sets
78
+ * it once; with none, a runtime behaves exactly as before.
79
+ */
80
+ observeModels = null;
75
81
  constructor(options) {
76
82
  this.dir = options.dir;
77
83
  this.secrets = options.secrets;
78
84
  this.bus = options.bus;
85
+ this.credentialLockOptions = options.credentialLock ?? {};
79
86
  }
80
87
  changed(payload = {}) {
81
88
  this.bus.emit({ type: 'accounts.changed', ...payload });
@@ -116,18 +123,40 @@ export class Accounts {
116
123
  return null;
117
124
  }
118
125
  }
126
+ credentialLock(key, work) {
127
+ return withCredentialLock(join(this.dir, '.credential-locks'), key, work, this.credentialLockOptions);
128
+ }
119
129
  /** Stores a provider's credential, or removes it when `credential` is `null`. */
120
130
  async writeCredential(accountId, provider, credential) {
121
131
  this.find(accountId);
122
132
  const name = validProvider(provider);
123
- if (credential === null)
124
- await this.secrets.delete(secretKey(accountId, `pi-auth/${name}`));
125
- else
126
- await this.secrets.set(secretKey(accountId, `pi-auth/${name}`), JSON.stringify(credential));
127
- store.updateState(this.dir, accountId, (state) => {
128
- state.piProviders = state.piProviders.filter((existing) => existing !== name);
129
- if (credential !== null)
130
- state.piProviders = [...state.piProviders, name].sort();
133
+ await this.credentialLock(`provider/${accountId}/${name}`, () => this.persistCredential(accountId, name, credential));
134
+ }
135
+ /** The entire refresh read/modify/write is coordinated across services and processes. */
136
+ async modifyCredential(accountId, provider, change) {
137
+ this.find(accountId);
138
+ const name = validProvider(provider);
139
+ return this.credentialLock(`provider/${accountId}/${name}`, async () => {
140
+ const current = (await this.readCredential(accountId, name)) ?? undefined;
141
+ const updated = await change(current);
142
+ if (updated !== undefined)
143
+ await this.persistCredential(accountId, name, updated);
144
+ return updated ?? current;
145
+ });
146
+ }
147
+ async persistCredential(accountId, name, credential) {
148
+ // Different providers still share the account's provider index. Keep its short commit serialized.
149
+ await this.credentialLock(`account/${accountId}`, async () => {
150
+ this.find(accountId);
151
+ if (credential === null)
152
+ await this.secrets.delete(secretKey(accountId, `pi-auth/${name}`));
153
+ else
154
+ await this.secrets.set(secretKey(accountId, `pi-auth/${name}`), JSON.stringify(credential));
155
+ store.updateState(this.dir, accountId, (state) => {
156
+ state.piProviders = state.piProviders.filter((existing) => existing !== name);
157
+ if (credential !== null)
158
+ state.piProviders = [...state.piProviders, name].sort();
159
+ });
131
160
  });
132
161
  }
133
162
  async listCredentials(accountId) {
@@ -140,25 +169,13 @@ export class Accounts {
140
169
  }
141
170
  return listed;
142
171
  }
143
- /** Pi's `CredentialStore` for one account. Writes are serialized, so a refresh never races another. */
172
+ /** Pi and explicit account edits share the same cross-process credential lock. */
144
173
  credentialStore(accountId) {
145
- let chain = Promise.resolve();
146
- const serialize = (work) => {
147
- const next = chain.then(work);
148
- chain = next.catch(() => undefined);
149
- return next;
150
- };
151
174
  return {
152
175
  read: async (providerId) => (await this.readCredential(accountId, providerId)) ?? undefined,
153
176
  list: () => this.listCredentials(accountId),
154
- modify: (providerId, change) => serialize(async () => {
155
- const current = (await this.readCredential(accountId, providerId)) ?? undefined;
156
- const updated = await change(current);
157
- if (updated !== undefined)
158
- await this.writeCredential(accountId, providerId, updated);
159
- return updated ?? current;
160
- }),
161
- delete: (providerId) => serialize(() => this.writeCredential(accountId, providerId, null))
177
+ modify: (providerId, change) => this.modifyCredential(accountId, providerId, change),
178
+ delete: (providerId) => this.writeCredential(accountId, providerId, null)
162
179
  };
163
180
  }
164
181
  /** Pi's OAuth device ID belongs to this Harness installation, not to one isolated account. */
@@ -187,6 +204,14 @@ export class Accounts {
187
204
  const configDir = 'configDir' in account ? account.configDir : env.ensureHome(this.dir, account);
188
205
  runtime = loadPi()
189
206
  .then(({ ModelRuntime }) => ModelRuntime.create({ credentials: this.credentialStore(id), modelsPath: join(configDir, 'models.json') }))
207
+ .then((created) => {
208
+ // Reading provider responses never changes what a request does; a broken observer is ignored.
209
+ try {
210
+ this.observeModels?.(created, id);
211
+ }
212
+ catch { /* observation is best effort */ }
213
+ return created;
214
+ })
190
215
  .catch((error) => {
191
216
  this.runtimes.delete(id);
192
217
  throw error;
@@ -690,6 +715,35 @@ export class Accounts {
690
715
  this.changed({ accountId, health: state.health, previous });
691
716
  }
692
717
  }
718
+ /**
719
+ * Records what a provider reported about its limits. An endpoint reading replaces that provider's
720
+ * limits; a header reading merges by id. Windows whose reset has passed are dropped. No credential
721
+ * is stored here.
722
+ */
723
+ applyLimits(accountId, readouts) {
724
+ this.find(accountId);
725
+ const expired = (limit) => {
726
+ const time = limit.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN;
727
+ return !Number.isNaN(time) && time <= Date.now();
728
+ };
729
+ store.updateState(this.dir, accountId, (state) => {
730
+ for (const readout of readouts) {
731
+ // An endpoint read is the provider's whole answer; a header read only adds what it saw.
732
+ const kept = new Map((readout.source === 'provider' ? [] : state.limits.filter((limit) => limit.provider === readout.provider && !expired(limit)))
733
+ .map((limit) => [limit.id, limit]));
734
+ for (const limit of readout.limits)
735
+ kept.set(limit.id, limit);
736
+ state.limits = [...state.limits.filter((limit) => limit.provider !== readout.provider), ...kept.values()];
737
+ const entry = { provider: readout.provider, source: readout.source, reason: readout.reason, observedAt: readout.observedAt };
738
+ state.providerLimits = [...state.providerLimits.filter((existing) => existing.provider !== readout.provider), entry];
739
+ if (readout.plan)
740
+ state.plan = readout.plan;
741
+ }
742
+ state.limits.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
743
+ });
744
+ this.changed({ accountId });
745
+ return this.overview();
746
+ }
693
747
  applyStatus(event) {
694
748
  const payload = event.payload;
695
749
  if (typeof payload.accountId !== 'string')
@@ -3,6 +3,18 @@ export interface SecretStore {
3
3
  set(key: string, value: string): Promise<void>;
4
4
  delete(key: string): Promise<void>;
5
5
  }
6
+ export interface CredentialLockOptions {
7
+ /** Acquisition only; an acquired callback is never unlocked while still running. */
8
+ timeoutMs?: number;
9
+ pollMs?: number;
10
+ }
11
+ /**
12
+ * Cross-process Lamport bakery lock. Unique participant directories make dead-pid reclamation
13
+ * safe: a stale cleanup can never unlink a successor's lock. No age-based stealing of live refreshes.
14
+ * The directory contains only hashed keys, pids, random IDs and tickets, never credential contents.
15
+ * All processes sharing a backend must use the same coordination directory on a local filesystem.
16
+ */
17
+ export declare function withCredentialLock<T>(dir: string, key: string, work: () => Promise<T>, options?: CredentialLockOptions): Promise<T>;
6
18
  /** `<account id>/<secret name>`: the key an account's secret is stored under. */
7
19
  export declare const secretKey: (accountId: string, name: string) => string;
8
20
  /** Secrets held only for the life of the process. For tests and throwaway runs. */
@@ -1,8 +1,115 @@
1
1
  // Where credentials live. The harness never writes a secret to its own files: it asks the
2
2
  // store the embedding application provides (a Keychain, an encrypted vault), by key.
3
- import { chmodSync, mkdirSync } from 'node:fs';
4
- import { dirname } from 'node:path';
5
- import { atomicWrite, readJsonOr } from "../core/fs.js";
3
+ import { chmodSync, closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { dirname, join } from 'node:path';
6
+ import { performance } from 'node:perf_hooks';
7
+ import { readJsonOr } from "../core/fs.js";
8
+ /** Exclusive temporary files are private before the first byte is written, not just after rename. */
9
+ function privateWrite(path, value) {
10
+ const temporary = join(dirname(path), `.credential-${randomUUID()}.tmp`);
11
+ try {
12
+ const fd = openSync(temporary, 'wx', 0o600);
13
+ try {
14
+ writeFileSync(fd, value);
15
+ fsyncSync(fd);
16
+ }
17
+ finally {
18
+ closeSync(fd);
19
+ }
20
+ renameSync(temporary, path);
21
+ }
22
+ finally {
23
+ rmSync(temporary, { force: true });
24
+ }
25
+ }
26
+ /**
27
+ * Cross-process Lamport bakery lock. Unique participant directories make dead-pid reclamation
28
+ * safe: a stale cleanup can never unlink a successor's lock. No age-based stealing of live refreshes.
29
+ * The directory contains only hashed keys, pids, random IDs and tickets, never credential contents.
30
+ * All processes sharing a backend must use the same coordination directory on a local filesystem.
31
+ */
32
+ export async function withCredentialLock(dir, key, work, options = {}) {
33
+ const timeout = options.timeoutMs ?? 30_000;
34
+ const poll = options.pollMs ?? 20;
35
+ if (!Number.isFinite(timeout) || timeout < 0 || timeout > 300_000 || !Number.isFinite(poll) || poll < 1 || poll > 1000) {
36
+ throw new Error('Invalid credential lock wait bounds.');
37
+ }
38
+ const started = performance.now();
39
+ const folder = join(dir, createHash('sha256').update(key).digest('hex'));
40
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
41
+ if (!lstatSync(dir).isDirectory() || !lstatSync(folder).isDirectory())
42
+ throw new Error('Invalid credential lock directory.');
43
+ chmodSync(dir, 0o700);
44
+ chmodSync(folder, 0o700);
45
+ const id = `${process.pid}-${randomUUID()}`;
46
+ const own = join(folder, id);
47
+ mkdirSync(own, { mode: 0o700 });
48
+ const participants = () => {
49
+ const entries = [];
50
+ for (const name of readdirSync(folder)) {
51
+ if (!/^\d+-[0-9a-f-]{36}$/.test(name))
52
+ throw new Error('Invalid credential lock metadata.');
53
+ const pid = Number(name.slice(0, name.indexOf('-')));
54
+ if (!Number.isSafeInteger(pid) || pid <= 0)
55
+ throw new Error('Invalid credential lock metadata.');
56
+ let alive = true;
57
+ try {
58
+ process.kill(pid, 0);
59
+ }
60
+ catch (error) {
61
+ if (error.code === 'ESRCH')
62
+ alive = false;
63
+ }
64
+ const participant = join(folder, name);
65
+ if (!alive) {
66
+ rmSync(participant, { recursive: true, force: true });
67
+ continue;
68
+ }
69
+ try {
70
+ // The absent ticket is the bakery algorithm's choosing flag.
71
+ const text = readFileSync(join(participant, 'ticket'), 'utf8');
72
+ const ticket = Number(text);
73
+ if (!Number.isSafeInteger(ticket) || ticket <= 0)
74
+ throw new Error('Invalid credential lock metadata.');
75
+ entries.push({ id: name, ticket });
76
+ }
77
+ catch (error) {
78
+ if (error.code !== 'ENOENT')
79
+ throw error;
80
+ if (lstatExists(participant))
81
+ entries.push({ id: name, ticket: null });
82
+ }
83
+ }
84
+ return entries;
85
+ };
86
+ try {
87
+ const ticket = Math.max(0, ...participants().map(entry => entry.ticket ?? 0)) + 1;
88
+ privateWrite(join(own, 'ticket'), String(ticket));
89
+ for (;;) {
90
+ const blocked = participants().some(entry => entry.id !== id && (entry.ticket === null || entry.ticket < ticket || (entry.ticket === ticket && entry.id < id)));
91
+ if (!blocked)
92
+ return await work();
93
+ if (performance.now() - started >= timeout)
94
+ throw new Error('Timed out waiting for credential modification; no credentials were changed.');
95
+ await new Promise(resolve => setTimeout(resolve, Math.min(poll, Math.max(1, timeout - (performance.now() - started)))));
96
+ }
97
+ }
98
+ finally {
99
+ rmSync(own, { recursive: true, force: true });
100
+ }
101
+ }
102
+ function lstatExists(path) {
103
+ try {
104
+ lstatSync(path);
105
+ return true;
106
+ }
107
+ catch (error) {
108
+ if (error.code === 'ENOENT')
109
+ return false;
110
+ throw error;
111
+ }
112
+ }
6
113
  /** `<account id>/<secret name>`: the key an account's secret is stored under. */
7
114
  export const secretKey = (accountId, name) => `${accountId}/${name}`;
8
115
  /** Secrets held only for the life of the process. For tests and throwaway runs. */
@@ -23,18 +130,18 @@ export function fileSecretStore(path) {
23
130
  const read = () => readJsonOr(path, {});
24
131
  const write = (values) => {
25
132
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
26
- atomicWrite(path, `${JSON.stringify(values, null, 2)}\n`);
133
+ privateWrite(path, `${JSON.stringify(values, null, 2)}\n`);
27
134
  chmodSync(path, 0o600);
28
135
  };
29
136
  return {
30
137
  get: async (key) => read()[key] ?? null,
31
- set: async (key, value) => write({ ...read(), [key]: value }),
32
- delete: async (key) => {
138
+ set: (key, value) => withCredentialLock(`${path}.locks`, 'file', async () => write({ ...read(), [key]: value })),
139
+ delete: (key) => withCredentialLock(`${path}.locks`, 'file', async () => {
33
140
  const values = read();
34
141
  if (key in values) {
35
142
  delete values[key];
36
143
  write(values);
37
144
  }
38
- }
145
+ })
39
146
  };
40
147
  }
@@ -25,10 +25,33 @@ export interface Limit {
25
25
  id: string;
26
26
  label: string;
27
27
  usedPercent?: number | null;
28
+ /** The same window from the other side: quota left. Additive for surfaces that read limits as remaining. */
29
+ remainingPercent?: number | null;
28
30
  resetsAt?: string | null;
29
31
  windowMinutes?: number | null;
30
- /** `allowed`, `warning`, or `rejected` when the provider reports it. */
32
+ /** `allowed`, `warning`, `rejected`, or `configured` when the provider reports it. */
31
33
  status?: string | null;
34
+ /** Which provider the reading belongs to. Older records may omit it. */
35
+ provider?: string | null;
36
+ /** What kind of ceiling this is. */
37
+ kind?: 'window' | 'rate' | 'credit' | 'configured' | 'error';
38
+ /** The unit of `used`, `limit` and `remaining`. */
39
+ unit?: 'percent' | 'requests' | 'tokens' | 'usd' | 'currency' | 'count';
40
+ used?: number | null;
41
+ limit?: number | null;
42
+ remaining?: number | null;
43
+ /** How the reading was obtained. */
44
+ source?: 'headers' | 'provider' | 'estimate' | 'error';
45
+ observedAt?: string | null;
46
+ /** A short provider note, such as a balance currency. */
47
+ detail?: string | null;
48
+ }
49
+ /** The last readout for one provider of one account, including when nothing could be read. */
50
+ export interface ProviderLimitState {
51
+ provider: string;
52
+ source: 'headers' | 'provider' | 'estimate' | 'unavailable';
53
+ reason: string | null;
54
+ observedAt: string;
32
55
  }
33
56
  export interface Issue {
34
57
  /** `auth_expired`, `rate_limited`, `quota`, `network`, or `unknown`. */
@@ -45,6 +68,8 @@ export interface AccountState {
45
68
  plan: string | null;
46
69
  identity: string | null;
47
70
  limits: Limit[];
71
+ /** What the last limit readout found for each provider, including an unavailable one. */
72
+ providerLimits: ProviderLimitState[];
48
73
  lastError: Issue | null;
49
74
  lastCheckedAt: string | null;
50
75
  cliVersion: string | null;
@@ -18,7 +18,7 @@ import { appendLine, newId, now, readJsonOr, safeId, writeJson } from "../core/f
18
18
  import { charCount, compareText, lines, takeChars } from "../core/text.js";
19
19
  /** The only provider the harness runs. Records of other providers are kept in the file and ignored. */
20
20
  export const PROVIDER = 'pi';
21
- export const emptyState = () => ({ health: 'unknown', signedIn: null, authMethod: null, plan: null, identity: null, limits: [], lastError: null, lastCheckedAt: null, cliVersion: null, capabilities: [], piProviders: [] });
21
+ export const emptyState = () => ({ health: 'unknown', signedIn: null, authMethod: null, plan: null, identity: null, limits: [], providerLimits: [], lastError: null, lastCheckedAt: null, cliVersion: null, capabilities: [], piProviders: [] });
22
22
  // ---- Files ------------------------------------------------------------------------------
23
23
  const registryPath = (dir) => join(dir, 'accounts.json');
24
24
  const statePath = (dir, accountId) => join(dir, safeId(accountId), 'state.json');
@@ -1,3 +1,4 @@
1
+ import { PLAN_RESEARCH_PLUGINS } from "./planResearch.js";
1
2
  const RECALL = ['history_search', 'history_read', 'trajectory_search', 'trajectory_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load'];
2
3
  const PROGRESS = ['checklist_write', 'session_name'];
3
4
  const WEB = ['web_search', 'web_fetch'];
@@ -29,6 +30,19 @@ export const BUILTIN_PROFILES = [
29
30
  '- Return conclusions, not file dumps: what you found, where, and how sure you are.'
30
31
  ].join('\n'),
31
32
  tools: ['read', 'grep', 'find', 'ls', ...PROGRESS, ...RECALL, ...WEB],
32
- plugins: null, variant: null, model: null, effort: null
33
+ plugins: [...PLAN_RESEARCH_PLUGINS], variant: null, model: null, effort: null
34
+ },
35
+ {
36
+ name: 'researcher',
37
+ description: 'Researches code, history and public sources without changing anything.',
38
+ color: 'purple',
39
+ instructions: [
40
+ 'You investigate the bounded research question in your brief and return concise, evidence-backed findings to the parent.',
41
+ '- Use read-only repository and public-web sources. Never modify files, run commands, call connected apps, or ask the user.',
42
+ '- Prefer primary sources. Cite repository claims as path:line and external claims with source URLs.',
43
+ '- Separate observed facts from inference, state uncertainty, and return only findings relevant to the brief; do not implement.'
44
+ ].join('\n'),
45
+ tools: ['read', 'grep', 'find', 'ls', ...PROGRESS, ...RECALL, ...WEB],
46
+ plugins: [...PLAN_RESEARCH_PLUGINS], variant: null, model: null, effort: null
33
47
  }
34
48
  ];
@@ -0,0 +1,5 @@
1
+ import type { AgentProfile } from "./profiles.js";
2
+ export declare const PLAN_RESEARCH_TOOLS: ReadonlySet<string>;
3
+ export declare const PLAN_RESEARCH_PLUGINS: readonly string[];
4
+ export declare const PLAN_RESEARCH_EVIDENCE_TOOLS: ReadonlySet<string>;
5
+ export declare function planResearchProfile(profile: Pick<AgentProfile, 'tools' | 'plugins'> | null | undefined): boolean;
@@ -0,0 +1,19 @@
1
+ export const PLAN_RESEARCH_TOOLS = new Set([
2
+ 'read', 'grep', 'find', 'ls', 'checklist_write', 'session_name',
3
+ 'history_search', 'history_read', 'trajectory_search', 'trajectory_read',
4
+ 'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load',
5
+ 'web_search', 'web_fetch'
6
+ ]);
7
+ export const PLAN_RESEARCH_PLUGINS = ['history', 'variant', 'web', 'wiki'];
8
+ export const PLAN_RESEARCH_EVIDENCE_TOOLS = new Set([
9
+ 'read', 'grep', 'find', 'ls', 'history_search', 'trajectory_search', 'wiki_search', 'web_search', 'web_fetch'
10
+ ]);
11
+ export function planResearchProfile(profile) {
12
+ return Boolean(profile
13
+ && Array.isArray(profile.tools)
14
+ && profile.tools.length > 0
15
+ && profile.tools.some(name => PLAN_RESEARCH_EVIDENCE_TOOLS.has(name))
16
+ && profile.tools.every(name => PLAN_RESEARCH_TOOLS.has(name))
17
+ && Array.isArray(profile.plugins)
18
+ && profile.plugins.every(name => PLAN_RESEARCH_PLUGINS.includes(name)));
19
+ }
@@ -71,10 +71,11 @@ export declare function removeProfile(root: string, cwd: string | null | undefin
71
71
  /** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
72
72
  export declare const PROFILE_SECTION_ORDER = 64;
73
73
  /** The prompt section that lists the profiles a run can start subagents from. */
74
- export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null): {
74
+ export declare function profileSection(root: string, cwd: string | null | undefined, variantDir?: string | null, planResearchOnly?: boolean): {
75
75
  id: string;
76
76
  title: string;
77
77
  body: string;
78
78
  budget: number;
79
79
  order: number;
80
+ dynamic: true;
80
81
  };
@@ -10,6 +10,7 @@ import { parse, serialize } from "../core/frontmatter.js";
10
10
  import { atomicWrite } from "../core/fs.js";
11
11
  import { subagentOf } from "../variant/materialise.js";
12
12
  import { BUILTIN_PROFILES } from "./builtin.js";
13
+ import { planResearchProfile } from "./planResearch.js";
13
14
  export const PROFILE_NAME = /^[a-z0-9][a-z0-9_-]*$/;
14
15
  export const PROFILE_COLORS = ['blue', 'green', 'yellow', 'red', 'magenta', 'cyan', 'orange', 'purple'];
15
16
  /** Nearest folder above `cwd` that holds `.git`, or `cwd` itself. */
@@ -206,16 +207,20 @@ export function removeProfile(root, cwd, input) {
206
207
  /** Where the profile list sits among the prompt sections: the slot a variant's subagents used. */
207
208
  export const PROFILE_SECTION_ORDER = 64;
208
209
  /** The prompt section that lists the profiles a run can start subagents from. */
209
- export function profileSection(root, cwd, variantDir) {
210
+ export function profileSection(root, cwd, variantDir, planResearchOnly = false) {
210
211
  const { profiles } = listProfiles(root, cwd, variantDir);
212
+ const listed = planResearchOnly ? profiles.filter(planResearchProfile) : profiles;
211
213
  return {
212
214
  id: 'subagents',
213
215
  title: 'Subagents',
214
216
  body: [
215
- 'Start one with spawn_subagent and agent set to a profile name, or omit agent for a copy of yourself. Each works in its own context with a complete brief from you, keeps its own checklist the user can follow, and returns text. A profile can only narrow your tools.',
216
- ...profiles.map((profile) => `- ${profile.name} — ${profile.description}`)
217
+ planResearchOnly
218
+ ? 'Plan mode may start only a named profile listed here, for read-only research. Do not omit agent or use a profile not listed.'
219
+ : 'Delegate when it meaningfully reduces work: choose a substantial, bounded task that can proceed independently, such as unfamiliar cross-file research likely to take several searches or a self-contained implementation. Do a known-file lookup, one or two simple tool calls, a tiny edit or tightly coupled work yourself. Prefer a named profile: explorer for repository research, researcher for public-source research, and worker for a defined change it can own end to end; omit agent only when no profile fits. Each child starts without this conversation, so give it the goal, relevant context or paths, non-goals, expected result and acceptance or verification; use its own checklist for multi-step work. Prefer context-heavy research with concise evidence-backed findings. Parallelize only independent tasks and never assign overlapping edits. Return without waiting only when useful independent work remains; otherwise wait. You own integration, verification and the user-facing report. A profile can only narrow your tools.',
220
+ ...(listed.length ? listed.map((profile) => `- ${profile.name} — ${profile.description}`) : ['- No eligible read-only research profiles are configured.'])
217
221
  ].join('\n'),
218
222
  budget: 6_000,
219
- order: PROFILE_SECTION_ORDER
223
+ order: PROFILE_SECTION_ORDER,
224
+ dynamic: true
220
225
  };
221
226
  }