agent-tempo 1.3.1 → 1.4.1

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 (199) hide show
  1. package/CLAUDE.md +39 -5
  2. package/README.md +6 -2
  3. package/dashboard/dist/assets/{index-D6Xyje_n.js → index-jmYe6rmS.js} +2 -2
  4. package/dashboard/dist/assets/index-jmYe6rmS.js.map +1 -0
  5. package/dashboard/dist/index.html +1 -1
  6. package/dashboard/package.json +1 -1
  7. package/dist/activities/outbox.d.ts +30 -1
  8. package/dist/activities/outbox.js +96 -3
  9. package/dist/adapters/base.js +5 -0
  10. package/dist/adapters/index.d.ts +1 -1
  11. package/dist/adapters/index.js +7 -0
  12. package/dist/adapters/pi/adapter.d.ts +2 -0
  13. package/dist/adapters/pi/adapter.js +43 -0
  14. package/dist/adapters/pi/index.d.ts +16 -0
  15. package/dist/adapters/pi/index.js +10 -0
  16. package/dist/client/core.js +9 -2
  17. package/dist/client/interface.d.ts +6 -0
  18. package/dist/config.d.ts +79 -0
  19. package/dist/config.js +74 -0
  20. package/dist/daemon.js +32 -1
  21. package/dist/http/aggregate.d.ts +22 -1
  22. package/dist/http/aggregate.js +41 -0
  23. package/dist/http/auth.d.ts +94 -8
  24. package/dist/http/auth.js +93 -9
  25. package/dist/http/body.d.ts +4 -1
  26. package/dist/http/body.js +6 -3
  27. package/dist/http/event-bus.js +1 -0
  28. package/dist/http/event-types.d.ts +34 -2
  29. package/dist/http/event-types.js +1 -0
  30. package/dist/http/gate-audit.d.ts +12 -0
  31. package/dist/http/gate-audit.js +95 -0
  32. package/dist/http/gate-registry.d.ts +167 -0
  33. package/dist/http/gate-registry.js +163 -0
  34. package/dist/http/gate-routes.d.ts +48 -0
  35. package/dist/http/gate-routes.js +102 -0
  36. package/dist/http/ingest-registry.d.ts +30 -0
  37. package/dist/http/ingest-registry.js +108 -0
  38. package/dist/http/inner-loop-routes.d.ts +66 -0
  39. package/dist/http/inner-loop-routes.js +182 -0
  40. package/dist/http/inner-loop.d.ts +92 -0
  41. package/dist/http/inner-loop.js +155 -0
  42. package/dist/http/server.d.ts +38 -3
  43. package/dist/http/server.js +211 -6
  44. package/dist/http/snapshot.d.ts +6 -0
  45. package/dist/http/snapshot.js +6 -0
  46. package/dist/pi/cue-pump.d.ts +61 -0
  47. package/dist/pi/cue-pump.js +95 -0
  48. package/dist/pi/extension.d.ts +45 -0
  49. package/dist/pi/extension.js +407 -0
  50. package/dist/pi/gate-client.d.ts +54 -0
  51. package/dist/pi/gate-client.js +136 -0
  52. package/dist/pi/headless.d.ts +85 -0
  53. package/dist/pi/headless.js +250 -0
  54. package/dist/pi/index.d.ts +28 -0
  55. package/dist/pi/index.js +43 -0
  56. package/dist/pi/inner-loop-client.d.ts +67 -0
  57. package/dist/pi/inner-loop-client.js +164 -0
  58. package/dist/pi/inner-loop-publisher.d.ts +187 -0
  59. package/dist/pi/inner-loop-publisher.js +236 -0
  60. package/dist/pi/lazy-proxy.d.ts +37 -0
  61. package/dist/pi/lazy-proxy.js +55 -0
  62. package/dist/pi/mission-control/actions.d.ts +48 -0
  63. package/dist/pi/mission-control/actions.js +98 -0
  64. package/dist/pi/mission-control/board.d.ts +88 -0
  65. package/dist/pi/mission-control/board.js +141 -0
  66. package/dist/pi/mission-control/extension.d.ts +51 -0
  67. package/dist/pi/mission-control/extension.js +330 -0
  68. package/dist/pi/mission-control/index.d.ts +15 -0
  69. package/dist/pi/mission-control/index.js +32 -0
  70. package/dist/pi/mission-control/inner-tail.d.ts +48 -0
  71. package/dist/pi/mission-control/inner-tail.js +76 -0
  72. package/dist/pi/mission-control/pi-ui.d.ts +43 -0
  73. package/dist/pi/mission-control/pi-ui.js +10 -0
  74. package/dist/pi/mission-control/render.d.ts +6 -0
  75. package/dist/pi/mission-control/render.js +98 -0
  76. package/dist/pi/phase-driver.d.ts +74 -0
  77. package/dist/pi/phase-driver.js +122 -0
  78. package/dist/pi/pi-types.d.ts +222 -0
  79. package/dist/pi/pi-types.js +21 -0
  80. package/dist/pi/probe.d.ts +99 -0
  81. package/dist/pi/probe.js +179 -0
  82. package/dist/pi/render-tools.d.ts +17 -0
  83. package/dist/pi/render-tools.js +56 -0
  84. package/dist/pi/reset-pump.d.ts +47 -0
  85. package/dist/pi/reset-pump.js +85 -0
  86. package/dist/pi/session-seed.d.ts +74 -0
  87. package/dist/pi/session-seed.js +103 -0
  88. package/dist/pi/tool-capability.d.ts +60 -0
  89. package/dist/pi/tool-capability.js +156 -0
  90. package/dist/pi/workflow-client.d.ts +158 -0
  91. package/dist/pi/workflow-client.js +289 -0
  92. package/dist/pi/zod-to-typebox.d.ts +74 -0
  93. package/dist/pi/zod-to-typebox.js +191 -0
  94. package/dist/server-tools.d.ts +2 -0
  95. package/dist/server-tools.js +50 -46
  96. package/dist/spawn.d.ts +55 -0
  97. package/dist/spawn.js +72 -0
  98. package/dist/tools/agent-types.d.ts +2 -2
  99. package/dist/tools/agent-types.js +22 -17
  100. package/dist/tools/attachment-info.d.ts +2 -2
  101. package/dist/tools/attachment-info.js +38 -33
  102. package/dist/tools/broadcast.d.ts +2 -2
  103. package/dist/tools/broadcast.js +69 -64
  104. package/dist/tools/cancel-stage.d.ts +2 -2
  105. package/dist/tools/cancel-stage.js +20 -15
  106. package/dist/tools/clear-state.d.ts +2 -2
  107. package/dist/tools/clear-state.js +25 -20
  108. package/dist/tools/coat-check-evict.d.ts +2 -2
  109. package/dist/tools/coat-check-evict.js +29 -24
  110. package/dist/tools/coat-check-get.d.ts +2 -2
  111. package/dist/tools/coat-check-get.js +38 -33
  112. package/dist/tools/coat-check-list.d.ts +2 -2
  113. package/dist/tools/coat-check-list.js +48 -43
  114. package/dist/tools/coat-check-put.d.ts +2 -2
  115. package/dist/tools/coat-check-put.js +38 -33
  116. package/dist/tools/cue.d.ts +2 -2
  117. package/dist/tools/cue.js +57 -52
  118. package/dist/tools/descriptor.d.ts +72 -0
  119. package/dist/tools/descriptor.js +39 -0
  120. package/dist/tools/destroy.d.ts +2 -2
  121. package/dist/tools/destroy.js +153 -148
  122. package/dist/tools/ensemble.d.ts +2 -2
  123. package/dist/tools/ensemble.js +71 -66
  124. package/dist/tools/evaluate-gate.d.ts +2 -2
  125. package/dist/tools/evaluate-gate.js +33 -27
  126. package/dist/tools/fetch-state.d.ts +2 -2
  127. package/dist/tools/fetch-state.js +42 -37
  128. package/dist/tools/gates.d.ts +2 -2
  129. package/dist/tools/gates.js +39 -34
  130. package/dist/tools/hosts.d.ts +2 -2
  131. package/dist/tools/hosts.js +25 -20
  132. package/dist/tools/listen.d.ts +2 -2
  133. package/dist/tools/listen.js +23 -18
  134. package/dist/tools/load-lineup.d.ts +2 -2
  135. package/dist/tools/load-lineup.js +324 -319
  136. package/dist/tools/migrate.d.ts +2 -2
  137. package/dist/tools/migrate.js +45 -40
  138. package/dist/tools/pause.d.ts +2 -2
  139. package/dist/tools/pause.js +34 -29
  140. package/dist/tools/play.d.ts +2 -2
  141. package/dist/tools/play.js +53 -48
  142. package/dist/tools/quality-gate.d.ts +2 -2
  143. package/dist/tools/quality-gate.js +26 -21
  144. package/dist/tools/recall.d.ts +2 -2
  145. package/dist/tools/recall.js +32 -27
  146. package/dist/tools/recruit.d.ts +2 -2
  147. package/dist/tools/recruit.js +340 -256
  148. package/dist/tools/release.d.ts +2 -2
  149. package/dist/tools/release.js +85 -80
  150. package/dist/tools/report.d.ts +2 -2
  151. package/dist/tools/report.js +28 -23
  152. package/dist/tools/reset.d.ts +3 -0
  153. package/dist/tools/reset.js +51 -0
  154. package/dist/tools/restart.d.ts +2 -2
  155. package/dist/tools/restart.js +51 -46
  156. package/dist/tools/restore.d.ts +2 -2
  157. package/dist/tools/restore.js +76 -71
  158. package/dist/tools/save-lineup.d.ts +2 -2
  159. package/dist/tools/save-lineup.js +32 -27
  160. package/dist/tools/save-state.d.ts +2 -2
  161. package/dist/tools/save-state.js +31 -26
  162. package/dist/tools/schedule.d.ts +2 -2
  163. package/dist/tools/schedule.js +133 -128
  164. package/dist/tools/schedules.d.ts +2 -2
  165. package/dist/tools/schedules.js +41 -36
  166. package/dist/tools/set-ensemble-description.d.ts +2 -2
  167. package/dist/tools/set-ensemble-description.js +26 -21
  168. package/dist/tools/set-name.d.ts +2 -2
  169. package/dist/tools/set-name.js +38 -33
  170. package/dist/tools/set-part.d.ts +2 -2
  171. package/dist/tools/set-part.js +20 -15
  172. package/dist/tools/shutdown.d.ts +2 -2
  173. package/dist/tools/shutdown.js +39 -34
  174. package/dist/tools/stage.d.ts +2 -2
  175. package/dist/tools/stage.js +28 -23
  176. package/dist/tools/stages.d.ts +2 -2
  177. package/dist/tools/stages.js +36 -31
  178. package/dist/tools/unschedule.d.ts +2 -2
  179. package/dist/tools/unschedule.js +30 -25
  180. package/dist/tools/who-am-i.d.ts +2 -2
  181. package/dist/tools/who-am-i.js +36 -31
  182. package/dist/tools/worktree.d.ts +2 -2
  183. package/dist/tools/worktree.js +134 -129
  184. package/dist/tui/index.js +6 -6
  185. package/dist/types.d.ts +47 -2
  186. package/dist/types.js +1 -1
  187. package/dist/utils/default-part.js +1 -0
  188. package/dist/utils/sdk-probe.d.ts +23 -0
  189. package/dist/utils/sdk-probe.js +46 -7
  190. package/dist/worker.d.ts +3 -1
  191. package/dist/worker.js +6 -2
  192. package/dist/workflows/session.js +70 -2
  193. package/dist/workflows/signals.d.ts +32 -2
  194. package/dist/workflows/signals.js +25 -2
  195. package/package.json +4 -1
  196. package/workflow-bundle.js +97 -6
  197. package/dashboard/dist/assets/index-D6Xyje_n.js.map +0 -1
  198. package/dist/tools/helpers.d.ts +0 -21
  199. package/dist/tools/helpers.js +0 -25
@@ -12,7 +12,7 @@
12
12
  rel="stylesheet"
13
13
  href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"
14
14
  />
15
- <script type="module" crossorigin src="/dashboard/assets/index-D6Xyje_n.js"></script>
15
+ <script type="module" crossorigin src="/dashboard/assets/index-jmYe6rmS.js"></script>
16
16
  <link rel="stylesheet" crossorigin href="/dashboard/assets/index-CB78ToNE.css">
17
17
  </head>
18
18
  <body>
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.3.1",
4
+ "version": "1.4.1",
5
5
  "type": "module",
6
6
  "description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
7
7
  "scripts": {
@@ -2,6 +2,8 @@ import { Client } from '@temporalio/client';
2
2
  import { Config } from '../config';
3
3
  import { AgentType, MockMode, DetachReason } from '../types';
4
4
  import type { ClaudeCodeHeadlessPermissionMode } from '../adapters/claude-code-headless/types';
5
+ import type { IngestTokenRegistry } from '../http/ingest-registry';
6
+ import type { GateRegistry } from '../http/gate-registry';
5
7
  import { type HardTerminateInput, type HardTerminateResult } from './hard-terminate';
6
8
  export interface DeliverCueInput {
7
9
  ensemble: string;
@@ -76,6 +78,17 @@ export interface DeliverDestroyInput {
76
78
  terminatedBy: string;
77
79
  notifyConductor?: boolean;
78
80
  }
81
+ export interface DeliverResetInput {
82
+ ensemble: string;
83
+ targetPlayerId: string;
84
+ /** Correlation id (the originating outbox entry id) — the extension acks with it. */
85
+ resetId: string;
86
+ /** Clean-wipe (D14 default true). */
87
+ fresh: boolean;
88
+ reason?: string;
89
+ /** Who requested the reset (audit). */
90
+ requestedBy?: string;
91
+ }
79
92
  export interface DeliverRestartInput {
80
93
  ensemble: string;
81
94
  targetPlayerId: string;
@@ -155,6 +168,14 @@ export interface SpawnProcessInput {
155
168
  * exclusive with {@link permissionMode}.
156
169
  */
157
170
  dangerouslySkipPermissions?: boolean;
171
+ /**
172
+ * Phase 3a / MD-C — headless Pi tool-class policy. Forwarded as
173
+ * `AGENT_TEMPO_TOOL_ACCESS`. Only meaningful when `agent === 'pi'`. One of
174
+ * `'restricted'` (default; Bash hard-blocked) | `'standard'` | `'full'`.
175
+ * Inline literal — kept off the workflow-sandbox import path (see the
176
+ * RecruitOutboxEntry note in src/types.ts).
177
+ */
178
+ toolAccess?: 'restricted' | 'standard' | 'full';
158
179
  }
159
180
  export interface OutboxActivityResult {
160
181
  success: boolean;
@@ -174,6 +195,7 @@ export interface OutboxActivities {
174
195
  deliverDetach(input: DeliverDetachInput): Promise<OutboxActivityResult>;
175
196
  deliverDestroy(input: DeliverDestroyInput): Promise<OutboxActivityResult>;
176
197
  deliverRestart(input: DeliverRestartInput): Promise<OutboxActivityResult>;
198
+ deliverReset(input: DeliverResetInput): Promise<OutboxActivityResult>;
177
199
  /**
178
200
  * OS-level child-process-tree kill for the target session. Runs on the per-host
179
201
  * task queue (`agent-tempo-{hostname}`) so the kill happens where the process
@@ -184,5 +206,12 @@ export interface OutboxActivities {
184
206
  /**
185
207
  * Create outbox delivery activities bound to a Temporal client and config.
186
208
  * The returned object is registered with the worker as activities.
209
+ *
210
+ * @param ingestTokens 3c Tier-2 ingest-auth registry (daemon-owned singleton,
211
+ * shared with the HTTP `/inner/ingest` + `/inner/presence` validators). The pi
212
+ * spawn branch MINTS a per-player token here (single-token-per-workflowId —
213
+ * re-mint REPLACES, so a restart naturally revokes the stale token); the
214
+ * destroy path REVOKES it. Optional: undefined disables ingest-token minting
215
+ * (e.g. the dev test harness that constructs activities without the daemon).
187
216
  */
188
- export declare function createOutboxActivities(client: Client, config: Config): OutboxActivities;
217
+ export declare function createOutboxActivities(client: Client, config: Config, ingestTokens?: IngestTokenRegistry, gate?: GateRegistry): OutboxActivities;
@@ -136,8 +136,15 @@ function classifyAndRethrow(err, contextPrefix) {
136
136
  /**
137
137
  * Create outbox delivery activities bound to a Temporal client and config.
138
138
  * The returned object is registered with the worker as activities.
139
+ *
140
+ * @param ingestTokens 3c Tier-2 ingest-auth registry (daemon-owned singleton,
141
+ * shared with the HTTP `/inner/ingest` + `/inner/presence` validators). The pi
142
+ * spawn branch MINTS a per-player token here (single-token-per-workflowId —
143
+ * re-mint REPLACES, so a restart naturally revokes the stale token); the
144
+ * destroy path REVOKES it. Optional: undefined disables ingest-token minting
145
+ * (e.g. the dev test harness that constructs activities without the daemon).
139
146
  */
140
- function createOutboxActivities(client, config) {
147
+ function createOutboxActivities(client, config, ingestTokens, gate) {
141
148
  return {
142
149
  async deliverCue(input) {
143
150
  const { ensemble, fromPlayerId, targetPlayerId, message, broadcastId, attachmentTicket } = input;
@@ -247,7 +254,7 @@ function createOutboxActivities(client, config) {
247
254
  // across CAN. Both adapters use the same `model` metadata field
248
255
  // (different value shapes — bare vs `provider/model`) — the spawn
249
256
  // path inspects `metadata.agentType` to know which env var to set.
250
- ...((agent === 'claude-api' || agent === 'opencode') && model ? { model } : {}),
257
+ ...((agent === 'claude-api' || agent === 'opencode' || agent === 'pi') && model ? { model } : {}),
251
258
  ...(agentDefinition ? { playerType: agentDefinition } : {}),
252
259
  ...(agentDefinitionDescription ? { playerTypeDescription: agentDefinitionDescription } : {}),
253
260
  recruitedBy: fromPlayerId,
@@ -305,7 +312,7 @@ function createOutboxActivities(client, config) {
305
312
  }
306
313
  },
307
314
  async spawnProcess(input) {
308
- const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume, sessionId, allowedTools, claudeBin, attachmentId, attachmentRunId, adapterId, mockMode, mockScenario, model, permissionMode, dangerouslySkipPermissions } = input;
315
+ const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume, sessionId, allowedTools, claudeBin, attachmentId, attachmentRunId, adapterId, mockMode, mockScenario, model, permissionMode, dangerouslySkipPermissions, toolAccess } = input;
309
316
  // Read secrets from the worker's config closure — never from workflow state
310
317
  const { temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = config;
311
318
  try {
@@ -436,6 +443,43 @@ function createOutboxActivities(client, config) {
436
443
  });
437
444
  log(`Spawned claude-code-headless adapter (pid ${pid}) in ${workDir} as "${targetName}"${permissionMode ? ` (permissionMode=${permissionMode})` : ''}${dangerouslySkipPermissions ? ' (dangerouslySkipPermissions=true)' : ''}${attachmentId ? ` (attachmentId=${attachmentId})` : ''}`);
438
445
  }
446
+ else if (agent === 'pi') {
447
+ // Phase 3a — headless Pi runtime. Injects the src/pi extension into
448
+ // Pi's createAgentSession; the module-scope singleton owns the
449
+ // lifecycle (claim/heartbeat/tools/cue-pump). Tool access is governed
450
+ // by the MD-C `toolAccess` policy (restricted hard-blocks Bash via the
451
+ // extension's tool_call gate), NOT per-tool allowlists.
452
+ if (allowedTools && allowedTools.length > 0) {
453
+ log(`Warning: allowedTools [${allowedTools.join(', ')}] specified for pi agent "${targetName}" — Pi tool access is governed by toolAccess (MD-C), skipping allowedTools`);
454
+ }
455
+ // 3c Tier-2 — mint a per-player ingest token scoped to this player's
456
+ // session workflowId so the headless Pi subprocess can authenticate its
457
+ // `POST /inner/ingest` frames. Single-token-per-workflowId (mint
458
+ // REPLACES) means a restart re-mints and naturally revokes the stale
459
+ // token. Injected into the subprocess env as AGENT_TEMPO_INGEST_TOKEN.
460
+ const ingestToken = ingestTokens?.mint((0, config_1.sessionWorkflowId)(ensemble, targetName));
461
+ const { pid } = (0, spawn_1.spawnPiHeadless)({
462
+ name: targetName,
463
+ ensemble,
464
+ temporalAddress,
465
+ temporalNamespace,
466
+ temporalApiKey,
467
+ temporalTlsCertPath,
468
+ temporalTlsKeyPath,
469
+ isConductor,
470
+ workDir,
471
+ model,
472
+ // Restart-resume: continue the prior Pi conversation only on a
473
+ // restart (resume=true); a fresh recruit starts a new Pi session.
474
+ continueSessionId: resume ? sessionId : undefined,
475
+ toolAccess,
476
+ attachmentId,
477
+ attachmentRunId,
478
+ adapterId,
479
+ ingestToken,
480
+ });
481
+ log(`Spawned pi headless adapter (pid ${pid}) in ${workDir} as "${targetName}" (toolAccess=${toolAccess ?? 'restricted'})${model ? ` (model=${model})` : ''}${resume && sessionId ? ` (continue=${sessionId})` : ''}${attachmentId ? ` (attachmentId=${attachmentId})` : ''}`);
482
+ }
439
483
  else {
440
484
  // Resolve agent flags: --agent (native) > --system-prompt (shipped/legacy)
441
485
  let agentFlags = [];
@@ -551,6 +595,14 @@ function createOutboxActivities(client, config) {
551
595
  throw activity_1.ApplicationFailure.nonRetryable(`Cannot detach "${targetPlayerId}" — session is destroyed`);
552
596
  }
553
597
  await handle.signal(signals_1.requestDetachSignal, { reason, deadlineMs });
598
+ // 3c Tier-2 — revoke the player's ingest token on detach so a dead
599
+ // holder's loopback POST /inner/ingest can no longer authenticate once
600
+ // the player goes away. Mirrors the destroy-side revoke; idempotent and a
601
+ // no-op for non-Pi players (they never minted one). Re-attach re-mints.
602
+ ingestTokens?.revoke((0, config_1.sessionWorkflowId)(ensemble, targetPlayerId));
603
+ // 3d MD-G — auto-disarm the gate on detach (the operator's gate posture
604
+ // shouldn't survive the player going away; re-attach re-arms). Idempotent.
605
+ gate?.clearPlayer((0, config_1.sessionWorkflowId)(ensemble, targetPlayerId));
554
606
  log(`Detach signaled for "${targetPlayerId}" (deadline=${deadlineMs}ms)`);
555
607
  return { success: true };
556
608
  }
@@ -580,6 +632,21 @@ function createOutboxActivities(client, config) {
580
632
  }],
581
633
  });
582
634
  log(`Destroyed "${targetPlayerId}"${reason ? ` (reason: ${reason})` : ''}`);
635
+ // 3c Tier-2 — revoke the player's ingest token on destroy so a dead
636
+ // holder's loopback POST /inner/ingest can no longer authenticate.
637
+ // Idempotent; a no-op for non-Pi players (they never minted one).
638
+ // NOTE (security cond. 2): the ingest endpoint authenticates on TOKEN
639
+ // ALONE — it does not inspect workflow phase (neither rejects nor
640
+ // buffers detached-phase players). So the residual surface is bounded
641
+ // not by phase-checking but by (a) this destroy-revoke and (b) the
642
+ // single-token-per-workflowId replacement on re-attach/restart.
643
+ // TODO(Phase 4): wire aggregate player-gone detection →
644
+ // revokeIngestToken(workflowId) on detach — deferred; residual surface
645
+ // negligible (dead holder + loopback-only + single-token replacement).
646
+ ingestTokens?.revoke((0, config_1.sessionWorkflowId)(ensemble, targetPlayerId));
647
+ // 3d MD-G — auto-disarm: drop the gate's armed-state + any pending
648
+ // requests for the destroyed player (idempotent; no-op for non-Pi).
649
+ gate?.clearPlayer((0, config_1.sessionWorkflowId)(ensemble, targetPlayerId));
583
650
  if (notifyConductor) {
584
651
  try {
585
652
  const condId = (0, config_1.conductorWorkflowId)(ensemble);
@@ -603,6 +670,32 @@ function createOutboxActivities(client, config) {
603
670
  classifyAndRethrow(err, `Destroy failed for "${targetPlayerId}"`);
604
671
  }
605
672
  },
673
+ /**
674
+ * D14 `deliverReset` — set a `pendingReset` flag on the target via
675
+ * `setPendingResetSignal` (a signal, like deliverCue — NOT a direct
676
+ * subprocess call). The Pi extension polls `pendingResetQuery`, performs the
677
+ * clean-wipe (`newSession`), then clears it via `ackResetSignal(resetId)`.
678
+ */
679
+ async deliverReset(input) {
680
+ const { ensemble, targetPlayerId, resetId, fresh, reason, requestedBy } = input;
681
+ try {
682
+ const handle = await (0, resolve_1.resolveSession)(client, ensemble, targetPlayerId);
683
+ if (!handle) {
684
+ throw activity_1.ApplicationFailure.nonRetryable(`No session found for "${targetPlayerId}"`);
685
+ }
686
+ await handle.signal(signals_1.setPendingResetSignal, {
687
+ resetId,
688
+ fresh,
689
+ ...(reason !== undefined ? { reason } : {}),
690
+ ...(requestedBy !== undefined ? { requestedBy } : {}),
691
+ });
692
+ log(`Reset queued for "${targetPlayerId}"${reason ? ` (reason: ${reason})` : ''}`);
693
+ return { success: true };
694
+ }
695
+ catch (err) {
696
+ classifyAndRethrow(err, `Reset failed for "${targetPlayerId}"`);
697
+ }
698
+ },
606
699
  /**
607
700
  * PR-D `deliverRestart` — owns the §8.2 restart algorithm on the target.
608
701
  * Graceful `requestDetach` → re-query phase → `forceDetach` (if --force
@@ -1264,6 +1264,11 @@ class AdapterRegistry {
1264
1264
  // Claude Code OAuth login so turns bill against subscription extra-usage.
1265
1265
  if (agent === 'claude-code-headless')
1266
1266
  return 'claude-code-headless';
1267
+ // Phase 3a — headless Pi runtime. The 'pi' descriptor is registry/identity
1268
+ // ONLY: the module-scope Pi extension singleton owns claim/heartbeat/phase
1269
+ // (no BaseAttachment subprocess driver). Opt-in via `recruit({ agent: 'pi' })`.
1270
+ if (agent === 'pi')
1271
+ return 'pi';
1267
1272
  return 'claude-code';
1268
1273
  }
1269
1274
  }
@@ -31,7 +31,7 @@ export declare const registry: AdapterRegistry;
31
31
  * Mock adapter is intentionally excluded — it's dev-mode-only and stripped
32
32
  * from the npm tarball at prepack time (ADR 0014 §7 gate 1).
33
33
  */
34
- export declare const CANONICAL_ADAPTER_IDS: readonly ["claude-code", "copilot", "claude-api", "opencode", "claude-code-headless"];
34
+ export declare const CANONICAL_ADAPTER_IDS: readonly ["claude-code", "copilot", "claude-api", "opencode", "claude-code-headless", "pi"];
35
35
  export { BaseAttachment, AdapterRegistry } from './base';
36
36
  export { SdkAttachment } from './sdk/base';
37
37
  export { InteractiveAttachment } from './claude-code';
@@ -24,6 +24,7 @@ const copilot_1 = require("./copilot");
24
24
  const claude_api_1 = require("./claude-api");
25
25
  const opencode_1 = require("./opencode");
26
26
  const claude_code_headless_1 = require("./claude-code-headless");
27
+ const pi_1 = require("./pi");
27
28
  const config_1 = require("../config");
28
29
  exports.registry = new base_1.AdapterRegistry();
29
30
  exports.registry.register(claude_code_1.claudeCodeDescriptor);
@@ -45,6 +46,11 @@ exports.registry.register(opencode_1.opencodeDescriptor);
45
46
  // `claude` binary is a system binary, not an npm dep; recruit pre-flight
46
47
  // probes for installation + login state via `pre-flight.ts`.
47
48
  exports.registry.register(claude_code_headless_1.claudeCodeHeadlessDescriptor);
49
+ // Phase 3a — headless Pi runtime. Registry/identity descriptor ONLY: the Pi
50
+ // extension singleton (Phase 2) owns the lifecycle, not a BaseAttachment driver
51
+ // (MD-D). Always-registered — the optional `@earendil-works/pi-coding-agent` SDK
52
+ // only matters at spawn time; recruit pre-flight gates on it (or `force: true`).
53
+ exports.registry.register(pi_1.piDescriptor);
48
54
  /**
49
55
  * Canonical list of production adapters registered at module-import time.
50
56
  *
@@ -65,6 +71,7 @@ exports.CANONICAL_ADAPTER_IDS = [
65
71
  'claude-api',
66
72
  'opencode',
67
73
  'claude-code-headless',
74
+ 'pi',
68
75
  ];
69
76
  // ADR 0014 §7 gate 2 — import-time registration gate. The mock adapter's
70
77
  // descriptor only enters the registry when `isDevMode()` is true.
@@ -0,0 +1,2 @@
1
+ /** Parse the spawn-provided env into options and run. Exported for testing. */
2
+ export declare function main(): Promise<void>;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.main = main;
4
+ /**
5
+ * Headless Pi adapter entry point (Phase 3a). Spawned as a detached subprocess
6
+ * by `spawnPiHeadless` (src/spawn.ts) on a recruited `agent: 'pi'`. Reads its
7
+ * config from the env the spawn set, then delegates to `runHeadlessPi`, which
8
+ * injects the agent-tempo extension into Pi's `createAgentSession`.
9
+ *
10
+ * Unlike the other headless adapters, there is NO `BaseAttachment` driver here:
11
+ * the module-scope Pi extension singleton (Phase 2) owns claim / heartbeat /
12
+ * phase / tool registration / cue pump (MD-D). This file is purely the process
13
+ * entry + env→options wiring.
14
+ *
15
+ * Dual-purpose: importing this module is inert; only running it as the process
16
+ * entry (`require.main === module`) boots the runtime.
17
+ */
18
+ const config_1 = require("../../config");
19
+ const headless_1 = require("../../pi/headless");
20
+ const log = (...args) => {
21
+ // eslint-disable-next-line no-console
22
+ console.error('[agent-tempo:pi]', ...args);
23
+ };
24
+ /** Normalize the env tool-access value to the MD-C policy (default 'restricted'). */
25
+ function readToolAccess() {
26
+ const raw = process.env[config_1.ENV.TOOL_ACCESS];
27
+ return raw === 'standard' || raw === 'full' ? raw : 'restricted';
28
+ }
29
+ /** Parse the spawn-provided env into options and run. Exported for testing. */
30
+ async function main() {
31
+ await (0, headless_1.runHeadlessPi)({
32
+ toolAccess: readToolAccess(),
33
+ model: process.env[config_1.ENV.PI_MODEL] || undefined,
34
+ continueSessionId: process.env[config_1.ENV.PI_CONTINUE_SESSION] || undefined,
35
+ });
36
+ }
37
+ if (require.main === module) {
38
+ main().catch((err) => {
39
+ log('FATAL — headless Pi adapter crashed:', err instanceof Error ? (err.stack ?? err.message) : err);
40
+ // eslint-disable-next-line no-process-exit
41
+ process.exit(1);
42
+ });
43
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pi adapter — registry/identity descriptor ONLY (Phase 3a).
3
+ *
4
+ * Unlike the other headless adapters (copilot / opencode / claude-api /
5
+ * claude-code-headless), Pi does NOT have a `BaseAttachment` subprocess driver.
6
+ * The headless Pi runtime injects the Phase 2 `src/pi` EXTENSION into Pi's
7
+ * `createAgentSession` as an inline factory; the module-scope extension singleton
8
+ * owns claim / heartbeat / phase / tool registration / cue pump (MD-D). This
9
+ * descriptor exists purely for adapter identity, registry resolution, spawn
10
+ * routing, and heartbeat-cadence config (MD-A: 30s heartbeat, lease = 3×).
11
+ *
12
+ * Spawn entry: `src/adapters/pi/adapter.ts` (A2) → `src/pi/headless.ts`.
13
+ */
14
+ import type { AdapterDescriptor } from '../../types';
15
+ /** Pi headless adapter descriptor. `sdk` class (blocks on the LLM turn); 30s heartbeat. */
16
+ export declare const piDescriptor: AdapterDescriptor;
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.piDescriptor = void 0;
4
+ /** Pi headless adapter descriptor. `sdk` class (blocks on the LLM turn); 30s heartbeat. */
5
+ exports.piDescriptor = {
6
+ adapterId: 'pi',
7
+ adapterClass: 'sdk',
8
+ blocksOnLLMTurn: true,
9
+ heartbeatMs: 30_000,
10
+ };
@@ -432,16 +432,21 @@ function createTempoClientCore(client, opts = {}) {
432
432
  // queries reject as `QueryTimeoutError`, `Promise.allSettled` sees
433
433
  // three rejections and the existing all-rejected branch returns
434
434
  // `null` — caller treats this player's wireMeta as missing.
435
- const [runIdR, messagingR, leaseR] = await Promise.allSettled([
435
+ const [runIdR, messagingR, leaseR, coarseR] = await Promise.allSettled([
436
436
  (0, query_timeout_1.queryHandleWithTimeout)(h, signals_1.getRunIdQuery),
437
437
  (0, query_timeout_1.queryHandleWithTimeout)(h, signals_1.getMessagingStateQuery),
438
438
  (0, query_timeout_1.queryHandleWithTimeout)(h, signals_1.getLeaseStateQuery),
439
+ // 3c Tier-1 — coarse activity (currentTool + context usage). Bounded like
440
+ // the others; an older session workflow without the handler rejects and
441
+ // is simply absent (additive/non-breaking).
442
+ (0, query_timeout_1.queryHandleWithTimeout)(h, signals_1.getCoarseActivityQuery),
439
443
  ]);
440
444
  // If every query rejected, treat this as "session unreachable" —
441
445
  // the caller renders no wire-meta rather than partial sentinels.
442
446
  if (runIdR.status === 'rejected' &&
443
447
  messagingR.status === 'rejected' &&
444
- leaseR.status === 'rejected') {
448
+ leaseR.status === 'rejected' &&
449
+ coarseR.status === 'rejected') {
445
450
  return null;
446
451
  }
447
452
  const out = {};
@@ -451,6 +456,8 @@ function createTempoClientCore(client, opts = {}) {
451
456
  out.messaging = messagingR.value;
452
457
  if (leaseR.status === 'fulfilled')
453
458
  out.lease = leaseR.value;
459
+ if (coarseR.status === 'fulfilled')
460
+ out.coarse = coarseR.value;
454
461
  return out;
455
462
  },
456
463
  async getMessages(ensemble, limit) {
@@ -253,6 +253,12 @@ export interface TempoClientCore {
253
253
  expiresAt: number | null;
254
254
  leaseMs: number | null;
255
255
  };
256
+ /** 3c Tier-1 — coarse activity (currentTool + context usage) from `getCoarseActivityQuery`. */
257
+ coarse?: {
258
+ currentTool: string | null;
259
+ contextTokens?: number;
260
+ contextPercent?: number;
261
+ };
256
262
  } | null>;
257
263
  /** Get recent messages for an ensemble. */
258
264
  getMessages(ensemble: string, limit?: number): Promise<MaestroRelayMessage[]>;
package/dist/config.d.ts CHANGED
@@ -48,6 +48,37 @@ export declare const ENV: {
48
48
  * sandboxed contexts. Mutually exclusive with {@link PERMISSION_MODE}.
49
49
  */
50
50
  readonly DANGEROUSLY_SKIP_PERMISSIONS: "AGENT_TEMPO_DANGEROUSLY_SKIP_PERMISSIONS";
51
+ /**
52
+ * Phase 3a — headless Pi runtime model selector. Pi takes a `provider/model`
53
+ * string (e.g. `anthropic/claude-opus-4-7`); absent → Pi's own default
54
+ * provider/model (the 3a anthropic-default path). Recruit `model` arg →
55
+ * this env → Pi default.
56
+ */
57
+ readonly PI_MODEL: "AGENT_TEMPO_PI_MODEL";
58
+ /**
59
+ * Phase 3a — headless Pi restart-resume. The daemon reads `metadata.sessionId`
60
+ * (the Pi conversation id the player was in when it died) and passes it here;
61
+ * the headless entry resumes via Pi `continueSession(<id>)`. Absent on a fresh
62
+ * recruit → a new Pi session.
63
+ */
64
+ readonly PI_CONTINUE_SESSION: "AGENT_TEMPO_PI_CONTINUE_SESSION";
65
+ /**
66
+ * Phase 3a / MD-C — headless Pi tool-access policy. One of
67
+ * `restricted` (default; Bash/shell/exec HARD-BLOCKED) | `standard` (scoped
68
+ * Bash) | `full` (unsandboxed; admin-gated at recruit). Read by the Pi
69
+ * extension's `tool_call` gate (mode='headless' only). Mirrors
70
+ * {@link PERMISSION_MODE}'s threading.
71
+ */
72
+ readonly TOOL_ACCESS: "AGENT_TEMPO_TOOL_ACCESS";
73
+ /**
74
+ * 3c Tier-2 ingest auth. The daemon mints a per-player ingest token (scoped to
75
+ * the session workflowId) BEFORE spawning a headless Pi player and threads it
76
+ * into the subprocess env here. The player's inner-loop publisher presents it
77
+ * on `POST /inner/ingest` + `GET /inner/presence` (loopback), where the daemon
78
+ * validates it against the URL-derived workflowId (cross-player-spoof guard).
79
+ * Absent → the publisher's HTTP client is a no-op (no fine-tail forwarding).
80
+ */
81
+ readonly INGEST_TOKEN: "AGENT_TEMPO_INGEST_TOKEN";
51
82
  /**
52
83
  * v0.25 PR-D attachment resume plumbing. When `restart` / `migrate`
53
84
  * enqueues a spawn outbox entry, the workflow passes the pre-claimed
@@ -72,6 +103,16 @@ export declare const ENV: {
72
103
  readonly DAEMON_PORT: "AGENT_TEMPO_DAEMON_PORT";
73
104
  readonly CORS_ORIGINS: "AGENT_TEMPO_CORS_ORIGINS";
74
105
  readonly SSE_MAX_CONNECTIONS: "AGENT_TEMPO_SSE_MAX_CONNECTIONS";
106
+ /**
107
+ * 3e RBAC (MD-E). Two-token model: the READ token (T1 — observe) may live in
108
+ * env or config.json and auto-generates; the ADMIN token (T1+T2+T3 — mutate +
109
+ * supervisory gate/inner) is ENV-VAR-ONLY (never config.json/disk, never
110
+ * auto-generated). `TLS_ACKNOWLEDGED=1` suppresses the non-loopback-bind
111
+ * plaintext-HTTP startup warning.
112
+ */
113
+ readonly HTTP_READ_TOKEN: "AGENT_TEMPO_HTTP_READ_TOKEN";
114
+ readonly HTTP_ADMIN_TOKEN: "AGENT_TEMPO_HTTP_ADMIN_TOKEN";
115
+ readonly TLS_ACKNOWLEDGED: "AGENT_TEMPO_TLS_ACKNOWLEDGED";
75
116
  /**
76
117
  * Dev profile gate (ADR 0014 §5.2). One source of truth — every layer
77
118
  * (paths, namespace, port, task queue, banner, registry gating) consults
@@ -115,8 +156,19 @@ export interface PersistedConfig {
115
156
  * a request with a non-loopback `Origin`) and no token is set:
116
157
  * `crypto.randomBytes(32).toString('base64url')`, 0600 on POSIX.
117
158
  * Rotation = delete this field; next daemon boot regenerates.
159
+ *
160
+ * 3e: this LEGACY single token is migrated to the READ tier (T1) — a daemon
161
+ * with only `httpToken` set keeps read access and emits a one-time startup
162
+ * warning to set an admin token for writes/gate/inner. Prefer `readToken`.
118
163
  */
119
164
  httpToken?: string;
165
+ /**
166
+ * 3e RBAC — the READ-tier (T1) bearer token. Env `AGENT_TEMPO_HTTP_READ_TOKEN`
167
+ * takes precedence over this; auto-generated here on first bearer-mode boot if
168
+ * neither is set. The ADMIN token is deliberately ABSENT from this file (it is
169
+ * env-var-only, never persisted).
170
+ */
171
+ readToken?: string;
120
172
  }
121
173
  /**
122
174
  * Dev profile defaults — one switch (`--dev` top-level flag, or
@@ -278,6 +330,33 @@ export declare function parseTemporalYaml(content: string): PersistedConfig;
278
330
  * for empty/unset values so callers can use it as a source-aware default.
279
331
  */
280
332
  export declare function parseAgent(value: string | undefined, source: ConfigSource): AgentType;
333
+ /**
334
+ * Result of {@link parsePiProviderModel}: the parsed parts, OR an `{ error }`
335
+ * describing why the selector is malformed. Non-throwing by design — a pure
336
+ * mapper returning a discriminated union (the recruit wiring branches
337
+ * `if ('error' in r) return fail(r.error)`, no try/catch).
338
+ */
339
+ export type ProviderModel = {
340
+ provider: string;
341
+ model: string;
342
+ } | {
343
+ error: string;
344
+ };
345
+ /**
346
+ * Parse a Pi provider/model selector (e.g. `"github-copilot/gpt-4o"`) into its
347
+ * `{ provider, model }` parts for Pi's `createAgentSession` model option.
348
+ *
349
+ * Provider-agnostic: the segment before the FIRST `/` is the provider id,
350
+ * passed through VERBATIM (Copilot's pi-ai provider id is literally
351
+ * `github-copilot` — no normalization needed); everything after is the model
352
+ * id, which may itself contain `/` (e.g. `openrouter/anthropic/claude`).
353
+ *
354
+ * Fail-loud (no silent default): returns `{ error }` — never a fallback model —
355
+ * when the selector has no `/`, an empty provider, or an empty model. A bare
356
+ * provider with no model is rejected here; omitting the recruit `model` arg
357
+ * ENTIRELY is a different path (Pi's own default), handled upstream, not here.
358
+ */
359
+ export declare function parsePiProviderModel(model: string): ProviderModel;
281
360
  /** CLI flag overrides — passed down from the arg parser. */
282
361
  export interface CliOverrides {
283
362
  temporalAddress?: string;
package/dist/config.js CHANGED
@@ -11,6 +11,7 @@ exports.saveConfigFile = saveConfigFile;
11
11
  exports.loadTemporalCliConfig = loadTemporalCliConfig;
12
12
  exports.parseTemporalYaml = parseTemporalYaml;
13
13
  exports.parseAgent = parseAgent;
14
+ exports.parsePiProviderModel = parsePiProviderModel;
14
15
  exports.getConfig = getConfig;
15
16
  exports.getConfigWithSources = getConfigWithSources;
16
17
  exports.hostTaskQueue = hostTaskQueue;
@@ -78,6 +79,37 @@ exports.ENV = {
78
79
  * sandboxed contexts. Mutually exclusive with {@link PERMISSION_MODE}.
79
80
  */
80
81
  DANGEROUSLY_SKIP_PERMISSIONS: 'AGENT_TEMPO_DANGEROUSLY_SKIP_PERMISSIONS',
82
+ /**
83
+ * Phase 3a — headless Pi runtime model selector. Pi takes a `provider/model`
84
+ * string (e.g. `anthropic/claude-opus-4-7`); absent → Pi's own default
85
+ * provider/model (the 3a anthropic-default path). Recruit `model` arg →
86
+ * this env → Pi default.
87
+ */
88
+ PI_MODEL: 'AGENT_TEMPO_PI_MODEL',
89
+ /**
90
+ * Phase 3a — headless Pi restart-resume. The daemon reads `metadata.sessionId`
91
+ * (the Pi conversation id the player was in when it died) and passes it here;
92
+ * the headless entry resumes via Pi `continueSession(<id>)`. Absent on a fresh
93
+ * recruit → a new Pi session.
94
+ */
95
+ PI_CONTINUE_SESSION: 'AGENT_TEMPO_PI_CONTINUE_SESSION',
96
+ /**
97
+ * Phase 3a / MD-C — headless Pi tool-access policy. One of
98
+ * `restricted` (default; Bash/shell/exec HARD-BLOCKED) | `standard` (scoped
99
+ * Bash) | `full` (unsandboxed; admin-gated at recruit). Read by the Pi
100
+ * extension's `tool_call` gate (mode='headless' only). Mirrors
101
+ * {@link PERMISSION_MODE}'s threading.
102
+ */
103
+ TOOL_ACCESS: 'AGENT_TEMPO_TOOL_ACCESS',
104
+ /**
105
+ * 3c Tier-2 ingest auth. The daemon mints a per-player ingest token (scoped to
106
+ * the session workflowId) BEFORE spawning a headless Pi player and threads it
107
+ * into the subprocess env here. The player's inner-loop publisher presents it
108
+ * on `POST /inner/ingest` + `GET /inner/presence` (loopback), where the daemon
109
+ * validates it against the URL-derived workflowId (cross-player-spoof guard).
110
+ * Absent → the publisher's HTTP client is a no-op (no fine-tail forwarding).
111
+ */
112
+ INGEST_TOKEN: 'AGENT_TEMPO_INGEST_TOKEN',
81
113
  /**
82
114
  * v0.25 PR-D attachment resume plumbing. When `restart` / `migrate`
83
115
  * enqueues a spawn outbox entry, the workflow passes the pre-claimed
@@ -102,6 +134,16 @@ exports.ENV = {
102
134
  DAEMON_PORT: 'AGENT_TEMPO_DAEMON_PORT',
103
135
  CORS_ORIGINS: 'AGENT_TEMPO_CORS_ORIGINS',
104
136
  SSE_MAX_CONNECTIONS: 'AGENT_TEMPO_SSE_MAX_CONNECTIONS',
137
+ /**
138
+ * 3e RBAC (MD-E). Two-token model: the READ token (T1 — observe) may live in
139
+ * env or config.json and auto-generates; the ADMIN token (T1+T2+T3 — mutate +
140
+ * supervisory gate/inner) is ENV-VAR-ONLY (never config.json/disk, never
141
+ * auto-generated). `TLS_ACKNOWLEDGED=1` suppresses the non-loopback-bind
142
+ * plaintext-HTTP startup warning.
143
+ */
144
+ HTTP_READ_TOKEN: 'AGENT_TEMPO_HTTP_READ_TOKEN',
145
+ HTTP_ADMIN_TOKEN: 'AGENT_TEMPO_HTTP_ADMIN_TOKEN',
146
+ TLS_ACKNOWLEDGED: 'AGENT_TEMPO_TLS_ACKNOWLEDGED',
105
147
  /**
106
148
  * Dev profile gate (ADR 0014 §5.2). One source of truth — every layer
107
149
  * (paths, namespace, port, task queue, banner, registry gating) consults
@@ -420,6 +462,38 @@ function parseAgent(value, source) {
420
462
  }
421
463
  return value;
422
464
  }
465
+ /**
466
+ * Parse a Pi provider/model selector (e.g. `"github-copilot/gpt-4o"`) into its
467
+ * `{ provider, model }` parts for Pi's `createAgentSession` model option.
468
+ *
469
+ * Provider-agnostic: the segment before the FIRST `/` is the provider id,
470
+ * passed through VERBATIM (Copilot's pi-ai provider id is literally
471
+ * `github-copilot` — no normalization needed); everything after is the model
472
+ * id, which may itself contain `/` (e.g. `openrouter/anthropic/claude`).
473
+ *
474
+ * Fail-loud (no silent default): returns `{ error }` — never a fallback model —
475
+ * when the selector has no `/`, an empty provider, or an empty model. A bare
476
+ * provider with no model is rejected here; omitting the recruit `model` arg
477
+ * ENTIRELY is a different path (Pi's own default), handled upstream, not here.
478
+ */
479
+ function parsePiProviderModel(model) {
480
+ const raw = model.trim();
481
+ const slash = raw.indexOf('/');
482
+ if (slash < 0) {
483
+ return {
484
+ error: `model "${model}" must be a "provider/model" selector (e.g. "github-copilot/gpt-4o") — no "/" found.`,
485
+ };
486
+ }
487
+ const provider = raw.slice(0, slash).trim();
488
+ const modelId = raw.slice(slash + 1).trim();
489
+ if (!provider) {
490
+ return { error: `model "${model}" has an empty provider before "/" — expected e.g. "github-copilot/gpt-4o".` };
491
+ }
492
+ if (!modelId) {
493
+ return { error: `model "${model}" has an empty model after "/" — specify a model, e.g. "github-copilot/gpt-4o".` };
494
+ }
495
+ return { provider, model: modelId };
496
+ }
423
497
  /**
424
498
  * Resolve `defaultAgent` through the standard precedence chain and validate
425
499
  * against the {@link AgentType} union. Each step passes its own source tag