agent-tempo 1.5.0 → 1.6.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.
@@ -9,21 +9,9 @@ import { InnerLoopPublisher } from './inner-loop-publisher';
9
9
  /** Runtime mode. Headless = recruited unsupervised player (MD-C gate active). */
10
10
  export type PiExtensionMode = 'interactive' | 'headless';
11
11
  export type PiToolAccess = 'restricted' | 'standard' | 'full';
12
- /**
13
- * B1 runtime guard (#645 H4) — the type gate's blind spot.
14
- *
15
- * `PiEventPayload.session` is UNDECLARED in Pi 0.78's `.d.ts` — it's an
16
- * interactive-only RUNTIME field, so the pi-drift type gate can't assert it. In
17
- * INTERACTIVE mode the `session_start` payload MUST carry `session` (the cue +
18
- * reset pumps inject into it); a null session there means injection is silently
19
- * inert — a likely Pi API drift. (Headless legitimately omits it — it wires
20
- * `rt.session` via `setRuntimeSession` — so the guard is interactive-only.)
21
- *
22
- * Pure + injected `warn` so it unit-tests without the workflow harness.
23
- */
24
- export declare function warnIfInteractiveSessionMissing(mode: PiExtensionMode, payload: {
12
+ export declare function noteInteractiveSessionAbsent(mode: PiExtensionMode, payload: {
25
13
  session?: unknown;
26
- }, warn: (msg: string) => void): void;
14
+ }, note: (msg: string) => void): void;
27
15
  export interface PiExtensionOptions {
28
16
  /** Default `'interactive'`. Headless installs the MD-C tool_call gate. */
29
17
  mode?: PiExtensionMode;
@@ -35,7 +23,7 @@ export interface PiExtensionOptions {
35
23
  * extension-instance rebuilds. Holds the durable attachment (handle + lease +
36
24
  * heartbeat, inside `wf`), the phase driver, the cue pump, and the session ptr.
37
25
  */
38
- interface PiPlayerRuntime {
26
+ export interface PiPlayerRuntime {
39
27
  readonly workflowId: string;
40
28
  readonly wf: PiWorkflowClient;
41
29
  readonly driver: PhaseDriver;
@@ -50,6 +38,19 @@ interface PiPlayerRuntime {
50
38
  /** 3d D14 — polls the workflow's pending reset → clean-wipe (newSession) + ack. */
51
39
  readonly reset: ResetPump;
52
40
  session: PiAgentSession | null;
41
+ /**
42
+ * #677 — THIS player's CURRENT Pi `ExtensionAPI` handle. Repointed on every
43
+ * instance rebuild (`session_start` re-bind) so the cue pump injects through the
44
+ * live `pi.sendMessage` (the stable interactive-injection path; Pi 0.78.1's
45
+ * SessionStartEvent has no `session` field). Re-resolved per tick — never captured.
46
+ */
47
+ pi: ExtensionAPI | null;
48
+ /**
49
+ * #677 — epoch-ms of the last observed `turn_start`/`agent_start`. The cue pump
50
+ * reads it to decide whether a sendMessage-injected cue actually woke a turn; if
51
+ * not, it escalates to `sendUserMessage`. `null` until the first turn starts.
52
+ */
53
+ lastTurnStartAt: number | null;
53
54
  lastSessionId?: string;
54
55
  }
55
56
  /**
@@ -94,6 +95,19 @@ export declare function __seedRuntimeForTests(workflowId: string, rt: PiPlayerRu
94
95
  * for the full singleton reset). TEST ESCAPE HATCH — do NOT call from production code.
95
96
  */
96
97
  export declare function __clearRuntimesForTests(): void;
98
+ /**
99
+ * Read the live runtime for a workflowId out of the module-scope map. TEST ESCAPE
100
+ * HATCH — do NOT call from production code. Used by the #677 rebind test to assert
101
+ * the post-switch tick injects through the NEW pi (cue pump) AND that the
102
+ * InnerLoopPublisher rebound to it.
103
+ */
104
+ export declare function __getPiRuntimeForTests(workflowId: string): PiPlayerRuntime | undefined;
105
+ /**
106
+ * Reset the one-time interactive-session breadcrumb flag (#677). TEST ESCAPE HATCH
107
+ * — do NOT call from production code. The flag is module-scope and fires at most
108
+ * once per process, so tests asserting the note must reset it between cases.
109
+ */
110
+ export declare function __resetInteractiveSessionNoteForTests(): void;
97
111
  /** Default export — interactive-mode extension (the human `pi` CLI entry). */
98
112
  declare const piExtension: (pi: ExtensionAPI) => void;
99
113
  export default piExtension;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.warnIfInteractiveSessionMissing = warnIfInteractiveSessionMissing;
36
+ exports.noteInteractiveSessionAbsent = noteInteractiveSessionAbsent;
37
37
  exports.createPiExtension = createPiExtension;
38
38
  exports.detachAllPiRuntimesForExit = detachAllPiRuntimesForExit;
39
39
  exports.setRuntimeSession = setRuntimeSession;
@@ -41,6 +41,8 @@ exports.__setPiClientFactoryForTests = __setPiClientFactoryForTests;
41
41
  exports.__resetPiRuntimesForTests = __resetPiRuntimesForTests;
42
42
  exports.__seedRuntimeForTests = __seedRuntimeForTests;
43
43
  exports.__clearRuntimesForTests = __clearRuntimesForTests;
44
+ exports.__getPiRuntimeForTests = __getPiRuntimeForTests;
45
+ exports.__resetInteractiveSessionNoteForTests = __resetInteractiveSessionNoteForTests;
44
46
  /**
45
47
  * agent-tempo Pi extension — interactive (Phase 2) + headless (Phase 3a) runtime.
46
48
  *
@@ -93,24 +95,37 @@ const log = (...args) => {
93
95
  console.error('[agent-tempo:pi]', ...args);
94
96
  };
95
97
  const nowIso = () => new Date().toISOString();
96
- const PI_AGENT_TYPE = 'claude'; // Pi is not yet a first-class AgentType.
98
+ // Pi IS a first-class AgentType (#666). #676 FIX-2: was a stale 'claude'
99
+ // placeholder — that made a Pi session misreport its agentType metadata AND
100
+ // recruit's mirror-fallback resolve to 'claude'.
101
+ const PI_AGENT_TYPE = 'pi';
97
102
  /**
98
- * B1 runtime guard (#645 H4) the type gate's blind spot.
103
+ * Interactive-session breadcrumb (#645 H4 reworded for #677).
99
104
  *
100
- * `PiEventPayload.session` is UNDECLARED in Pi 0.78's `.d.ts` it's an
101
- * interactive-only RUNTIME field, so the pi-drift type gate can't assert it. In
102
- * INTERACTIVE mode the `session_start` payload MUST carry `session` (the cue +
103
- * reset pumps inject into it); a null session there means injection is silently
104
- * inert a likely Pi API drift. (Headless legitimately omits it it wires
105
- * `rt.session` via `setRuntimeSession` so the guard is interactive-only.)
105
+ * Pi 0.78.1's `SessionStartEvent` carries NO `session` field, so in INTERACTIVE
106
+ * mode `payload.session` is null. Pre-#677 that meant cue/reset injection was inert
107
+ * (it read `payload.session`), so this was a WARNING. Post-#677 injection routes
108
+ * through the stable `pi.sendMessage` handle (re-resolved per tick) and a missing
109
+ * session is the EXPECTED path NOT an error. The "is `pi.sendMessage` still
110
+ * wired?" correctness signal now lives at BUILD time in the H4 drift gate
111
+ * (`test/pi-drift/assert.ts` `_passSendMsg` / `_sendSurfaceCallShape`), so this
112
+ * runtime check's correctness role is redundant.
106
113
  *
107
- * Pure + injected `warn` so it unit-tests without the workflow harness.
114
+ * What remains is value as a ONE-TIME, non-alarming boot breadcrumb: during Pi
115
+ * bring-up it confirms at a glance "you're on the expected 0.78.1 no-session path;
116
+ * cues route via pi.sendMessage." Fires AT MOST ONCE per process (the
117
+ * module-scope `notedInteractiveSessionAbsent` flag) — not per switch, not per
118
+ * tick. Pure + injected `note` so it unit-tests without the workflow harness.
108
119
  */
109
- function warnIfInteractiveSessionMissing(mode, payload, warn) {
110
- if (mode === 'interactive' && payload.session == null) {
111
- warn('WARNING: interactive session_start carried no session cue/reset injection inert; ' +
112
- 'possible Pi API drift (#645)');
113
- }
120
+ let notedInteractiveSessionAbsent = false;
121
+ function noteInteractiveSessionAbsent(mode, payload, note) {
122
+ if (mode !== 'interactive' || payload.session != null)
123
+ return;
124
+ if (notedInteractiveSessionAbsent)
125
+ return;
126
+ notedInteractiveSessionAbsent = true;
127
+ note('interactive session_start has no `session` field — expected on Pi ≥0.78.1; ' +
128
+ 'cues/reset route via pi.sendMessage (re-resolved per tick).');
114
129
  }
115
130
  // MD-C shell/exec tool-class membership is owned by `tool-capability.ts`
116
131
  // (`classify(name) === 'exec'`, content signed off by tempo-security). F1
@@ -173,6 +188,21 @@ function createPiExtension(options = {}) {
173
188
  };
174
189
  (0, render_tools_1.renderToPi)(pi, (0, server_tools_1.buildAllTempoTools)(toolOpts));
175
190
  log(`registered tools (player=${currentPlayerId}, conductor=${isConductor}, mode=${mode})`);
191
+ // ── #677 PART B — interactive-only `/tempo-reset` command ──
192
+ // Pi's `newSession` (clean-wipe) is ExtensionCommandContext-ONLY (not on the
193
+ // SDK session), so an interactive Pi conductor can ONLY be reset by the operator
194
+ // running this command. The reset pump's interactive branch notifies the
195
+ // operator to run it when a peer/conductor requests a reset (operator-mediated
196
+ // is the ceiling). Headless players have no command surface → not registered.
197
+ if (mode === 'interactive' && typeof pi.registerCommand === 'function') {
198
+ pi.registerCommand('tempo-reset', {
199
+ description: "Clean-wipe this Pi session's context (agent-tempo reset).",
200
+ handler: async (_args, ctx) => {
201
+ log('/tempo-reset — clean-wiping session context (newSession)');
202
+ await ctx.newSession();
203
+ },
204
+ });
205
+ }
176
206
  // ── MD-C tool-access gate (HEADLESS ONLY) ──
177
207
  // Interactive Pi = a human owns their machine → no gate. Headless = recruited
178
208
  // unsupervised → MD-C governs tool access. TOOL-CLASS CHECK FIRST: shell/exec
@@ -258,6 +288,16 @@ function createPiExtension(options = {}) {
258
288
  const existing = runtimes.get(workflowId);
259
289
  if (existing) {
260
290
  existing.session = payload.session ?? existing.session;
291
+ // #677 — repoint to THIS instance's `pi`. Pi rebuilds the extension
292
+ // instance on every session switch; the surviving runtime + its cue pump
293
+ // (created on first attach) re-resolve the injector from `rt.pi` each tick,
294
+ // so the cue pump injects through the LIVE handle, not the stale one.
295
+ existing.pi = pi;
296
+ // #677 FREEBIE — the InnerLoopPublisher was captured-once on the FIRST pi
297
+ // and goes stale after a switch (README:251 carry-item — same root cause).
298
+ // Re-start it on the new pi so its `pi.on(...)` observers track the live
299
+ // instance. `start()` re-registers handlers; its flush timer is idempotent.
300
+ existing.pub.start(pi);
261
301
  log(`re-bound ${currentPlayerId} (Pi instance rebuilt; lease intact)`);
262
302
  return existing;
263
303
  }
@@ -271,7 +311,11 @@ function createPiExtension(options = {}) {
271
311
  const driver = new phase_driver_1.PhaseDriver();
272
312
  const pump = new cue_pump_1.CuePump({
273
313
  source: wf,
274
- resolveSession: () => runtimes.get(workflowId)?.session ?? null,
314
+ // #677 re-resolve the injector from the SURVIVING runtime each tick:
315
+ // prefer `rt.pi.sendMessage` (stable interactive path), fall back to
316
+ // `rt.session.sendCustomMessage`. Reading `runtimes.get(workflowId)` (not a
317
+ // captured `rt`) is what makes a post-rebind tick use the NEW pi.
318
+ resolveInjector: () => (0, cue_pump_1.buildPiInjector)(runtimes.get(workflowId) ?? null),
275
319
  });
276
320
  // 3c — inner-loop publisher + its loopback-HTTP sink. The client no-ops
277
321
  // unless AGENT_TEMPO_INGEST_TOKEN is present (daemon-spawned headless
@@ -285,8 +329,16 @@ function createPiExtension(options = {}) {
285
329
  const reset = new reset_pump_1.ResetPump({
286
330
  source: wf,
287
331
  resolveSession: () => runtimes.get(workflowId)?.session ?? null,
332
+ // #677 PART B — interactive can't auto-wipe (no session field / newSession is
333
+ // command-context-only); the reset pump notifies the operator via this handle.
334
+ resolvePi: () => runtimes.get(workflowId)?.pi ?? null,
288
335
  });
289
- const rt = { workflowId, wf, driver, pump, pub, reset, session: payload.session ?? null };
336
+ const rt = {
337
+ workflowId, wf, driver, pump, pub, reset,
338
+ session: payload.session ?? null,
339
+ pi, // #677 — first-attach instance's pi (repointed on each rebind)
340
+ lastTurnStartAt: null,
341
+ };
290
342
  runtimes.set(workflowId, rt);
291
343
  await wf.ensureSessionWorkflow();
292
344
  const result = driver.handle('session_start', payload, nowIso());
@@ -303,8 +355,9 @@ function createPiExtension(options = {}) {
303
355
  }
304
356
  // ── Lifecycle: session_start → first attach OR re-bind ──
305
357
  pi.on('session_start', async (payload) => {
306
- // B1 (#645 H4): warn loudly if interactive session_start lost its session.
307
- warnIfInteractiveSessionMissing(mode, payload, log);
358
+ // #677: one-time INFO breadcrumb when interactive session_start has no
359
+ // `session` field (the expected 0.78.1 path — injection routes via pi.sendMessage).
360
+ noteInteractiveSessionAbsent(mode, payload, log);
308
361
  try {
309
362
  const rt = await attachOrRebind(payload);
310
363
  await refreshSessionId(rt, rt.session?.id);
@@ -321,6 +374,10 @@ function createPiExtension(options = {}) {
321
374
  return;
322
375
  if (payload.session)
323
376
  rt.session = payload.session;
377
+ // #677 — a turn has begun → stamp for the cue pump's escalation check (so
378
+ // a sendMessage-injected cue that DID wake a turn is not re-escalated).
379
+ if (event === 'agent_start')
380
+ rt.lastTurnStartAt = Date.now();
324
381
  const result = rt.driver.handle(event, payload, nowIso());
325
382
  try {
326
383
  await rt.wf.performAction(result.action);
@@ -342,6 +399,9 @@ function createPiExtension(options = {}) {
342
399
  return;
343
400
  if (payload.session)
344
401
  rt.session = payload.session;
402
+ // #677 — turn_start marks a live turn for the cue pump's escalation check.
403
+ if (event === 'turn_start')
404
+ rt.lastTurnStartAt = Date.now();
345
405
  rt.driver.handle(event, payload, nowIso());
346
406
  });
347
407
  }
@@ -441,6 +501,23 @@ function __seedRuntimeForTests(workflowId, rt) {
441
501
  function __clearRuntimesForTests() {
442
502
  runtimes.clear();
443
503
  }
504
+ /**
505
+ * Read the live runtime for a workflowId out of the module-scope map. TEST ESCAPE
506
+ * HATCH — do NOT call from production code. Used by the #677 rebind test to assert
507
+ * the post-switch tick injects through the NEW pi (cue pump) AND that the
508
+ * InnerLoopPublisher rebound to it.
509
+ */
510
+ function __getPiRuntimeForTests(workflowId) {
511
+ return runtimes.get(workflowId);
512
+ }
513
+ /**
514
+ * Reset the one-time interactive-session breadcrumb flag (#677). TEST ESCAPE HATCH
515
+ * — do NOT call from production code. The flag is module-scope and fires at most
516
+ * once per process, so tests asserting the note must reset it between cases.
517
+ */
518
+ function __resetInteractiveSessionNoteForTests() {
519
+ notedInteractiveSessionAbsent = false;
520
+ }
444
521
  /** Default export — interactive-mode extension (the human `pi` CLI entry). */
445
522
  const piExtension = createPiExtension();
446
523
  exports.default = piExtension;
@@ -18,8 +18,8 @@ export { PhaseDriver } from './phase-driver';
18
18
  export type { PiPhase, WorkflowAction, PhaseDriverResult } from './phase-driver';
19
19
  export { PiWorkflowClient } from './workflow-client';
20
20
  export type { PiWorkflowClientOptions } from './workflow-client';
21
- export { CuePump } from './cue-pump';
22
- export type { CueSource, SessionResolver, CuePumpOptions } from './cue-pump';
21
+ export { CuePump, buildPiInjector } from './cue-pump';
22
+ export type { CueSource, MessageInjector, InjectorResolver, InjectorRuntime, CuePumpOptions, } from './cue-pump';
23
23
  export { renderToPi, toPiResult } from './render-tools';
24
24
  export { createLazyProxy } from './lazy-proxy';
25
25
  export { zodShapeToTypeBox, UnsupportedZodFeatureError } from './zod-to-typebox';
package/dist/pi/index.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.PI_NODE_FLOOR = exports.TESTED_PI_VERSION = exports.PI_AI_PACKAGE = exports.PI_PACKAGE = exports.probePi = exports.UnsupportedZodFeatureError = exports.zodShapeToTypeBox = exports.createLazyProxy = exports.toPiResult = exports.renderToPi = exports.CuePump = exports.PiWorkflowClient = exports.PhaseDriver = exports.default = void 0;
6
+ exports.PI_NODE_FLOOR = exports.TESTED_PI_VERSION = exports.PI_AI_PACKAGE = exports.PI_PACKAGE = exports.probePi = exports.UnsupportedZodFeatureError = exports.zodShapeToTypeBox = exports.createLazyProxy = exports.toPiResult = exports.renderToPi = exports.buildPiInjector = exports.CuePump = exports.PiWorkflowClient = exports.PhaseDriver = exports.default = void 0;
7
7
  /**
8
8
  * agent-tempo Pi integration — barrel.
9
9
  *
@@ -27,6 +27,7 @@ var workflow_client_1 = require("./workflow-client");
27
27
  Object.defineProperty(exports, "PiWorkflowClient", { enumerable: true, get: function () { return workflow_client_1.PiWorkflowClient; } });
28
28
  var cue_pump_1 = require("./cue-pump");
29
29
  Object.defineProperty(exports, "CuePump", { enumerable: true, get: function () { return cue_pump_1.CuePump; } });
30
+ Object.defineProperty(exports, "buildPiInjector", { enumerable: true, get: function () { return cue_pump_1.buildPiInjector; } });
30
31
  var render_tools_1 = require("./render-tools");
31
32
  Object.defineProperty(exports, "renderToPi", { enumerable: true, get: function () { return render_tools_1.renderToPi; } });
32
33
  Object.defineProperty(exports, "toPiResult", { enumerable: true, get: function () { return render_tools_1.toPiResult; } });
@@ -243,10 +243,60 @@ export interface PiToolDefinition {
243
243
  */
244
244
  execute: (toolCallId: string, params: Record<string, unknown>, signal?: unknown, onUpdate?: unknown, ctx?: unknown) => Promise<PiToolResult> | PiToolResult;
245
245
  }
246
+ /**
247
+ * The context Pi passes to a registered command handler (#677 PART B). NARROW
248
+ * structural slice — `/tempo-reset` only calls `newSession()` (clean-wipe).
249
+ * Pi's real `ExtensionCommandContext` (much larger) is assignable to this.
250
+ *
251
+ * `newSession` is command-context-ONLY in Pi — it is NOT on the SDK session
252
+ * object — which is exactly why an interactive Pi conductor CANNOT be auto-reset:
253
+ * the reset pump can only NOTIFY the operator to run `/tempo-reset` themselves
254
+ * (operator-mediated is the ceiling; see reset-pump.ts).
255
+ */
256
+ export interface PiCommandContext {
257
+ /** Start a FRESH session (clean-wipe, no replay). */
258
+ newSession(): Promise<{
259
+ cancelled: boolean;
260
+ }>;
261
+ }
262
+ /** Options for {@link ExtensionAPI.registerCommand} (#677 PART B) — the slice we set. */
263
+ export interface PiCommandOptions {
264
+ description?: string;
265
+ handler: (args: string, ctx: PiCommandContext) => Promise<void>;
266
+ }
246
267
  /** The `pi` object passed to `export default function(pi: ExtensionAPI) {}`. */
247
268
  export interface ExtensionAPI {
248
269
  on(event: PiLifecycleEvent | string, handler: PiEventHandler): void;
249
270
  registerTool(def: PiToolDefinition): void;
271
+ /**
272
+ * Register an interactive slash command (#677 PART B — `/tempo-reset`). Optional
273
+ * in the slice: only the interactive Pi CLI surfaces commands (headless has no
274
+ * command surface). Kept loose by the architect's registerCommand ruling — see
275
+ * test/pi-drift/assert.ts `_registerSurfaceExists`.
276
+ */
277
+ registerCommand?(name: string, options: PiCommandOptions): void;
278
+ /**
279
+ * Inject a custom message into the live session through the STABLE `pi` handle
280
+ * (#677). Same message shape as `PiAgentSession.sendCustomMessage`'s param0
281
+ * (`Pick<CustomMessage, "customType"|"content"|"display"|"details">`). This is
282
+ * the interactive cue-injection path: Pi 0.78.1's `SessionStartEvent` carries
283
+ * NO `session` field, so `PiEventPayload.session` is null in interactive mode —
284
+ * routing cues through `pi.sendMessage` (re-resolved per tick from the surviving
285
+ * runtime) injects reliably regardless. Optional in the slice (Pi provides it; a
286
+ * fake/older Pi may not — the cue pump feature-detects with `typeof`).
287
+ */
288
+ sendMessage?(message: PiOutboundMessage, options?: PiCustomMessageOptions): void;
289
+ /**
290
+ * Inject a USER-role message — ALWAYS triggers a turn (#677 escalation path).
291
+ * When `sendMessage`'s `triggerTurn` fails to wake a cold-idle agent, the cue
292
+ * pump re-injects the SAME cue via this user-role call (a user message always
293
+ * starts a turn). It LOSES the `cue` customType + operator-vs-peer steer/followUp
294
+ * semantics, so it is FALLBACK-ONLY (never the primary route). Optional in the
295
+ * slice for the same reason as `sendMessage`.
296
+ */
297
+ sendUserMessage?(content: string, options?: {
298
+ deliverAs?: 'steer' | 'followUp';
299
+ }): void;
250
300
  }
251
301
  /** An extension is a default-exported function receiving the `ExtensionAPI`. */
252
302
  export type PiExtension = (pi: ExtensionAPI) => void | Promise<void>;
@@ -1,19 +1,29 @@
1
1
  /**
2
- * Reset pump (3d D14) — polls the session workflow's single-slot pending reset
3
- * and performs a CLEAN-WIPE on the live Pi session, then acks it. Sibling to
4
- * {@link CuePump}: Pi has no reverse-RPC from Temporal, so reset (an operator/
5
- * conductor CONTROL op — it bypasses the MD-G tool gate) is delivered by polling
6
- * `pendingReset` and acking via the race-safe `ackReset(resetId)` (the workflow
7
- * clears the slot only if the id still matches, so a newer reset landing during
8
- * the wipe is preserved for the next tick).
2
+ * Reset pump (3d D14 + #677 PART B) — polls the session workflow's single-slot
3
+ * pending reset and DELIVERS it, then acks. Sibling to {@link CuePump}: Pi has no
4
+ * reverse-RPC from Temporal, so reset (an operator/conductor CONTROL op — it
5
+ * bypasses the MD-G tool gate) is delivered by polling `pendingReset` and acking
6
+ * via the race-safe `ackReset(resetId)` (the workflow clears the slot only if the
7
+ * id still matches, so a newer reset landing during delivery is preserved).
9
8
  *
10
- * D14 (maintainer-ruled): reset = clean-wipe via Pi `session.newSession()` (fresh
11
- * context, NO replay). Seeded reset is a separate concern (`restart` +
12
- * `loadFromState`), so a `fresh:false` here is defensively logged + acked (never
13
- * silently wiped) — the reset tool only ever sends `fresh:true` today.
9
+ * D14 (maintainer-ruled): reset = clean-wipe (fresh context, NO replay). Seeded
10
+ * reset is a separate concern (`restart` + `loadFromState`), so a `fresh:false`
11
+ * here is defensively logged + acked — the reset tool only ever sends `fresh:true`.
12
+ *
13
+ * ── CAPABILITY BRANCH (#677 PART B) ──
14
+ * Delivery depends on what's attached, NOT a mode flag:
15
+ * 1. HEADLESS / session-capable — `session.newSession()` exists → AUTO clean-wipe
16
+ * in place, then ack.
17
+ * 2. INTERACTIVE — Pi 0.78.1's SessionStartEvent has no `session` field, so
18
+ * `rt.session` is null AND `newSession` is command-context-ONLY (not on the
19
+ * SDK session): the pump CANNOT auto-wipe an interactive conductor. Instead it
20
+ * NOTIFIES the operator (via the stable `pi.sendMessage` handle) to run
21
+ * `/tempo-reset` themselves — ACK-ON-NOTIFY, id-matched so the notice fires
22
+ * ONCE per resetId (no per-tick spam). Operator-mediated is the ceiling.
23
+ * 3. Nothing attached yet — leave pending; retry next tick.
14
24
  */
15
25
  import type { PendingReset } from '../types';
16
- import type { PiAgentSession } from './pi-types';
26
+ import type { ExtensionAPI, PiAgentSession } from './pi-types';
17
27
  /** Source of the pending reset + ack — satisfied by `PiWorkflowClient`. */
18
28
  export interface ResetSource {
19
29
  fetchPendingReset(): Promise<PendingReset | null>;
@@ -21,27 +31,55 @@ export interface ResetSource {
21
31
  }
22
32
  /** Resolves the CURRENT live Pi session at wipe time (re-acquired each tick — D11). */
23
33
  export type SessionResolver = () => PiAgentSession | null;
34
+ /** Resolves the CURRENT Pi `ExtensionAPI` handle (interactive operator-notice route — D11). */
35
+ export type PiResolver = () => ExtensionAPI | null;
24
36
  export interface ResetPumpOptions {
25
37
  source: ResetSource;
26
38
  resolveSession: SessionResolver;
39
+ /**
40
+ * #677 PART B — the live `pi` handle for the interactive operator-notice route.
41
+ * Re-resolved each tick (repointed on instance rebuild). Absent → no notify path
42
+ * (legacy/headless-only callers); the pump still auto-wipes when a session with
43
+ * `newSession()` is present.
44
+ */
45
+ resolvePi?: PiResolver;
27
46
  /** Poll interval (ms). */
28
47
  intervalMs?: number;
29
48
  }
30
49
  export declare class ResetPump {
31
50
  private readonly source;
32
51
  private readonly resolveSession;
52
+ private readonly resolvePi;
33
53
  private readonly intervalMs;
34
54
  private timer;
35
55
  private draining;
56
+ /**
57
+ * #677 PART B — the resetId we've already surfaced as an operator notice, so the
58
+ * "run /tempo-reset" notice fires ONCE per request (id-matched). Cleared when the
59
+ * slot empties or a wipe happens.
60
+ */
61
+ private lastNotifiedResetId;
36
62
  constructor(opts: ResetPumpOptions);
37
63
  start(): void;
38
64
  stop(): void;
39
65
  /**
40
- * One poll cycle: fetch the pending reset; if present + a live session is
41
- * attached, perform the wipe and ack. Re-entrancy guarded so a slow tick never
42
- * overlaps the next interval. Public for unit tests to drive directly.
66
+ * One poll cycle (#677 PART B capability branch). Re-entrancy guarded so a slow
67
+ * tick never overlaps the next interval. Public for unit tests to drive directly.
68
+ *
69
+ * 1. no pending → clear dedup, done.
70
+ * 2. fresh=false → log + ack (clear slot; seeded reset is restart's job).
71
+ * 3. session.newSession() avail → AUTO clean-wipe + ack (headless / session-capable).
72
+ * 4. else pi.sendMessage avail → operator notice (once per id) + ack (interactive).
73
+ * 5. else → nothing attached yet; leave pending, retry.
43
74
  */
44
75
  tick(): Promise<void>;
45
- /** Wipe (D14 clean-wipe) + deliver the "context wiped" notice. */
46
- private performReset;
76
+ /** D14 clean-wipe (caller guarantees `fresh` + `newSession`) + the "context wiped" notice. */
77
+ private performWipe;
78
+ /**
79
+ * Interactive operator notice (#677 PART B). The pump can't reach `newSession`
80
+ * (command-context-only), so it asks the human to run `/tempo-reset`. Sent via
81
+ * the stable `pi.sendMessage` handle, non-triggering (it's an instruction, not a
82
+ * turn). Best-effort: a failed notice never throws the tick.
83
+ */
84
+ private notifyOperator;
47
85
  }
@@ -9,12 +9,20 @@ const log = (...args) => {
9
9
  class ResetPump {
10
10
  source;
11
11
  resolveSession;
12
+ resolvePi;
12
13
  intervalMs;
13
14
  timer = null;
14
15
  draining = false;
16
+ /**
17
+ * #677 PART B — the resetId we've already surfaced as an operator notice, so the
18
+ * "run /tempo-reset" notice fires ONCE per request (id-matched). Cleared when the
19
+ * slot empties or a wipe happens.
20
+ */
21
+ lastNotifiedResetId = null;
15
22
  constructor(opts) {
16
23
  this.source = opts.source;
17
24
  this.resolveSession = opts.resolveSession;
25
+ this.resolvePi = opts.resolvePi ?? (() => null);
18
26
  this.intervalMs = opts.intervalMs ?? DEFAULT_POLL_MS;
19
27
  }
20
28
  start() {
@@ -33,9 +41,14 @@ class ResetPump {
33
41
  }
34
42
  }
35
43
  /**
36
- * One poll cycle: fetch the pending reset; if present + a live session is
37
- * attached, perform the wipe and ack. Re-entrancy guarded so a slow tick never
38
- * overlaps the next interval. Public for unit tests to drive directly.
44
+ * One poll cycle (#677 PART B capability branch). Re-entrancy guarded so a slow
45
+ * tick never overlaps the next interval. Public for unit tests to drive directly.
46
+ *
47
+ * 1. no pending → clear dedup, done.
48
+ * 2. fresh=false → log + ack (clear slot; seeded reset is restart's job).
49
+ * 3. session.newSession() avail → AUTO clean-wipe + ack (headless / session-capable).
50
+ * 4. else pi.sendMessage avail → operator notice (once per id) + ack (interactive).
51
+ * 5. else → nothing attached yet; leave pending, retry.
39
52
  */
40
53
  async tick() {
41
54
  if (this.draining)
@@ -43,30 +56,48 @@ class ResetPump {
43
56
  this.draining = true;
44
57
  try {
45
58
  const pr = await this.source.fetchPendingReset();
46
- if (!pr)
59
+ if (!pr) {
60
+ this.lastNotifiedResetId = null; // slot empty → forget the last notice
47
61
  return;
62
+ }
63
+ if (!pr.fresh) {
64
+ // D14: reset is clean-wipe ONLY. A seeded reset is restart+loadFromState
65
+ // (not this path). Don't guess — log + ack (clear the slot).
66
+ log(`reset ${pr.resetId}: fresh=false — no wipe (seeded reset is restart's job)`);
67
+ await this.source.ackReset(pr.resetId);
68
+ this.lastNotifiedResetId = null;
69
+ return;
70
+ }
71
+ // (3) Session-capable (headless) → auto clean-wipe in place.
48
72
  const session = this.resolveSession();
49
- if (!session)
50
- return; // no live session yet — leave it pending; next tick retries
51
- await this.performReset(session, pr);
52
- await this.source.ackReset(pr.resetId);
73
+ if (session && typeof session.newSession === 'function') {
74
+ await this.performWipe(session, pr);
75
+ await this.source.ackReset(pr.resetId);
76
+ this.lastNotifiedResetId = null;
77
+ return;
78
+ }
79
+ // (4) Interactive → can't auto-wipe; notify the operator to run /tempo-reset.
80
+ const pi = this.resolvePi();
81
+ if (pi && typeof pi.sendMessage === 'function') {
82
+ if (this.lastNotifiedResetId !== pr.resetId) {
83
+ this.notifyOperator(pi, pr); // ONCE per resetId (no per-tick spam)
84
+ this.lastNotifiedResetId = pr.resetId;
85
+ }
86
+ // ACK-ON-NOTIFY: the request has been DELIVERED to the operator (the most
87
+ // an interactive conductor can do); clear the slot so it doesn't re-poll.
88
+ await this.source.ackReset(pr.resetId);
89
+ return;
90
+ }
91
+ // (5) Nothing attached yet — leave it pending; next tick retries.
53
92
  }
54
93
  finally {
55
94
  this.draining = false;
56
95
  }
57
96
  }
58
- /** Wipe (D14 clean-wipe) + deliver the "context wiped" notice. */
59
- async performReset(session, pr) {
60
- if (!pr.fresh) {
61
- // D14: reset is clean-wipe ONLY. A seeded reset would be restart+loadFromState
62
- // (not this path). Don't guess — log + fall through to ack (clear the slot).
63
- log(`reset ${pr.resetId}: fresh=false — no wipe (seeded reset is restart's job)`);
64
- return;
65
- }
66
- if (typeof session.newSession !== 'function') {
67
- log(`reset ${pr.resetId}: session.newSession() unavailable — skipping wipe (will still ack)`);
68
- return;
69
- }
97
+ /** D14 clean-wipe (caller guarantees `fresh` + `newSession`) + the "context wiped" notice. */
98
+ async performWipe(session, pr) {
99
+ // `newSession` is optional on the slice; tick() gated `typeof === 'function'`
100
+ // before calling, so the assertion is sound (the doc-comment states the contract).
70
101
  await session.newSession(); // clean-wipe: fresh context, no replay
71
102
  const by = pr.requestedBy ? ` (requested by ${pr.requestedBy})` : '';
72
103
  const notice = `[reset] context wiped — fresh start${by}.${pr.reason ? ` reason: ${pr.reason}` : ''}`;
@@ -81,5 +112,24 @@ class ResetPump {
81
112
  log(`reset ${pr.resetId}: notice injection failed (non-fatal):`, err);
82
113
  }
83
114
  }
115
+ /**
116
+ * Interactive operator notice (#677 PART B). The pump can't reach `newSession`
117
+ * (command-context-only), so it asks the human to run `/tempo-reset`. Sent via
118
+ * the stable `pi.sendMessage` handle, non-triggering (it's an instruction, not a
119
+ * turn). Best-effort: a failed notice never throws the tick.
120
+ */
121
+ notifyOperator(pi, pr) {
122
+ const by = pr.requestedBy ? ` by ${pr.requestedBy}` : '';
123
+ const reason = pr.reason ? ` (reason: ${pr.reason})` : '';
124
+ const notice = `⟳ context reset requested${by}${reason} — run /tempo-reset to clean-wipe this ` +
125
+ `session's context. agent-tempo can't auto-reset an interactive Pi conductor.`;
126
+ try {
127
+ pi.sendMessage?.({ customType: 'system', content: notice, display: true }, { deliverAs: 'followUp', triggerTurn: false });
128
+ log(`reset ${pr.resetId}: interactive — notified operator to run /tempo-reset`);
129
+ }
130
+ catch (err) {
131
+ log(`reset ${pr.resetId}: operator notice failed (non-fatal):`, err);
132
+ }
133
+ }
84
134
  }
85
135
  exports.ResetPump = ResetPump;
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
20
20
  import { Client, WorkflowHandle } from '@temporalio/client';
21
- import { Config } from './config';
21
+ import { Config, ConfigSource } from './config';
22
22
  import { AgentType } from './types';
23
23
  import { type TempoToolDescriptor } from './tools/descriptor';
24
24
  /**
@@ -45,6 +45,12 @@ export interface RegisterAllTempoToolsOpts {
45
45
  workflowId: string;
46
46
  /** Default agent for `recruit` when the caller doesn't override. */
47
47
  ownAgentType: AgentType;
48
+ /**
49
+ * #676 FIX-1 — the SOURCE of `config.defaultAgent` (getConfigWithSources().sources),
50
+ * so recruit can prefer an operator-SET default over the `ownAgentType` mirror.
51
+ * Optional → undefined preserves the pre-FIX-1 mirror fallback.
52
+ */
53
+ defaultAgentSource?: ConfigSource;
48
54
  /** Whether this player is the ensemble's conductor (gates conductor-only tools). */
49
55
  isConductor: boolean;
50
56
  }
@@ -59,14 +59,14 @@ const coat_check_evict_1 = require("./tools/coat-check-evict");
59
59
  * surface.
60
60
  */
61
61
  function buildAllTempoTools(opts) {
62
- const { client, config, getPlayerId, setPlayerId, handle, workflowId, ownAgentType, isConductor } = opts;
62
+ const { client, config, getPlayerId, setPlayerId, handle, workflowId, ownAgentType, defaultAgentSource, isConductor } = opts;
63
63
  const tools = [
64
64
  (0, ensemble_1.buildEnsembleTool)(client, config, getPlayerId, workflowId),
65
65
  (0, cue_1.buildCueTool)(client, config, getPlayerId, handle),
66
66
  (0, set_part_1.buildSetPartTool)(handle),
67
67
  (0, set_name_1.buildSetNameTool)(client, config, handle, getPlayerId, setPlayerId),
68
68
  (0, listen_1.buildListenTool)(handle),
69
- (0, recruit_1.buildRecruitTool)(client, config, getPlayerId, handle, ownAgentType),
69
+ (0, recruit_1.buildRecruitTool)(client, config, getPlayerId, handle, ownAgentType, defaultAgentSource),
70
70
  (0, report_1.buildReportTool)(handle),
71
71
  (0, schedule_1.buildScheduleTool)(client, config, getPlayerId),
72
72
  (0, unschedule_1.buildUnscheduleTool)(client, config),