agent-tempo 1.5.1 → 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.
- package/dashboard/package.json +1 -1
- package/dist/activities/outbox.d.ts +14 -1
- package/dist/activities/outbox.js +41 -0
- package/dist/pi/cue-pump.d.ts +86 -13
- package/dist/pi/cue-pump.js +102 -15
- package/dist/pi/extension.d.ts +29 -15
- package/dist/pi/extension.js +96 -19
- package/dist/pi/index.d.ts +2 -2
- package/dist/pi/index.js +2 -1
- package/dist/pi/pi-types.d.ts +50 -0
- package/dist/pi/reset-pump.d.ts +55 -17
- package/dist/pi/reset-pump.js +70 -20
- package/dist/server-tools.d.ts +7 -1
- package/dist/server-tools.js +2 -2
- package/dist/server.js +5 -2
- package/dist/tools/recruit.d.ts +19 -2
- package/dist/tools/recruit.js +26 -2
- package/package.json +1 -1
package/dashboard/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from '@temporalio/client';
|
|
2
2
|
import { Config } from '../config';
|
|
3
|
-
import { AgentType, MockMode, DetachReason } from '../types';
|
|
3
|
+
import { AgentType, AttachmentPhase, MockMode, DetachReason } from '../types';
|
|
4
4
|
import type { ClaudeCodeHeadlessPermissionMode } from '../adapters/claude-code-headless/types';
|
|
5
5
|
import type { IngestTokenRegistry } from '../http/ingest-registry';
|
|
6
6
|
import type { GateRegistry } from '../http/gate-registry';
|
|
@@ -180,6 +180,12 @@ export interface SpawnProcessInput {
|
|
|
180
180
|
export interface OutboxActivityResult {
|
|
181
181
|
success: boolean;
|
|
182
182
|
error?: string;
|
|
183
|
+
/**
|
|
184
|
+
* Human-readable note for a non-failure outcome the caller may surface — e.g.
|
|
185
|
+
* #676 FIX-3's "skipped duplicate spawn" no-op. Floor today is the daemon log;
|
|
186
|
+
* structured here so a future workflow-side relay can surface it to the operator.
|
|
187
|
+
*/
|
|
188
|
+
note?: string;
|
|
183
189
|
}
|
|
184
190
|
export interface RecruitResult extends OutboxActivityResult {
|
|
185
191
|
/** Session UUID assigned at recruit time. */
|
|
@@ -214,4 +220,11 @@ export interface OutboxActivities {
|
|
|
214
220
|
* destroy path REVOKES it. Optional: undefined disables ingest-token minting
|
|
215
221
|
* (e.g. the dev test harness that constructs activities without the daemon).
|
|
216
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* #676 FIX-3 — should spawnProcess SKIP as a duplicate dispatch? TRUE iff this is
|
|
225
|
+
* a FRESH recruit (no `attachmentId` handoff) AND a live adapter is already
|
|
226
|
+
* attached. A restart/migrate carries `attachmentId` (the handoff to its fresh
|
|
227
|
+
* claim — phase is legitimately live) → never skipped. Pure + exported for tests.
|
|
228
|
+
*/
|
|
229
|
+
export declare function shouldSkipDuplicateSpawn(attachmentId: string | undefined, phase: AttachmentPhase): boolean;
|
|
217
230
|
export declare function createOutboxActivities(client: Client, config: Config, ingestTokens?: IngestTokenRegistry, gate?: GateRegistry): OutboxActivities;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.shouldSkipDuplicateSpawn = shouldSkipDuplicateSpawn;
|
|
36
37
|
exports.createOutboxActivities = createOutboxActivities;
|
|
37
38
|
const client_1 = require("@temporalio/client");
|
|
38
39
|
const activity_1 = require("@temporalio/activity");
|
|
@@ -144,6 +145,17 @@ function classifyAndRethrow(err, contextPrefix) {
|
|
|
144
145
|
* destroy path REVOKES it. Optional: undefined disables ingest-token minting
|
|
145
146
|
* (e.g. the dev test harness that constructs activities without the daemon).
|
|
146
147
|
*/
|
|
148
|
+
/**
|
|
149
|
+
* #676 FIX-3 — should spawnProcess SKIP as a duplicate dispatch? TRUE iff this is
|
|
150
|
+
* a FRESH recruit (no `attachmentId` handoff) AND a live adapter is already
|
|
151
|
+
* attached. A restart/migrate carries `attachmentId` (the handoff to its fresh
|
|
152
|
+
* claim — phase is legitimately live) → never skipped. Pure + exported for tests.
|
|
153
|
+
*/
|
|
154
|
+
function shouldSkipDuplicateSpawn(attachmentId, phase) {
|
|
155
|
+
if (attachmentId)
|
|
156
|
+
return false; // restart/migrate handoff — must spawn
|
|
157
|
+
return phase === 'attached' || phase === 'processing' || phase === 'awaiting';
|
|
158
|
+
}
|
|
147
159
|
function createOutboxActivities(client, config, ingestTokens, gate) {
|
|
148
160
|
return {
|
|
149
161
|
async deliverCue(input) {
|
|
@@ -315,6 +327,35 @@ function createOutboxActivities(client, config, ingestTokens, gate) {
|
|
|
315
327
|
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;
|
|
316
328
|
// Read secrets from the worker's config closure — never from workflow state
|
|
317
329
|
const { temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = config;
|
|
330
|
+
// #676 FIX-3 — double-dispatch backstop (ACTIVITY-level; no workflow/bundle
|
|
331
|
+
// touch). A FRESH recruit (NO attachmentId) of a name that ALREADY has a live
|
|
332
|
+
// adapter is a duplicate dispatch → skip the spawn so we don't race a second
|
|
333
|
+
// adapter for the lease. attachmentId PRESENT = a restart/migrate HANDOFF to a
|
|
334
|
+
// fresh claim (phase is legitimately {attached|processing|awaiting} from that
|
|
335
|
+
// claim) → MUST NOT skip, or restart attaches to a non-existent adapter.
|
|
336
|
+
// Guard ABOVE the agent switch so it covers every agent. TOCTOU best-effort —
|
|
337
|
+
// claimAttachment's expectedAttachmentId arbitrates the rare race.
|
|
338
|
+
if (!attachmentId) {
|
|
339
|
+
const checkWorkflowId = isConductor ? (0, config_1.conductorWorkflowId)(ensemble) : (0, config_1.sessionWorkflowId)(ensemble, targetName);
|
|
340
|
+
try {
|
|
341
|
+
const info = await client.workflow.getHandle(checkWorkflowId).query(signals_1.attachmentInfoQuery);
|
|
342
|
+
if (shouldSkipDuplicateSpawn(attachmentId, info.phase)) {
|
|
343
|
+
// Corrected message (architect): force does NOT bypass this skip under
|
|
344
|
+
// FIX-3(a), so do NOT tell the operator to pass force. Replace-a-live-
|
|
345
|
+
// adapter is restart/migrate's lane; a stale session self-heals in ~90s.
|
|
346
|
+
const note = `recruit skipped: player "${targetName}" is already attached (phase=${info.phase}) — ` +
|
|
347
|
+
`spawning now would create a duplicate adapter racing the live session. To replace it, ` +
|
|
348
|
+
`use \`restart\` (same host) or \`migrate\` (other host). If the session is actually stale, ` +
|
|
349
|
+
`its lease expires and it's reaped within ~90s, after which recruit spawns normally.`;
|
|
350
|
+
log(`[#676 FIX-3] ${note}`);
|
|
351
|
+
return { success: true, note };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
// Not-queryable-yet / transient → fall through to spawn (best-effort guard).
|
|
356
|
+
log(`FIX-3 attachment pre-check inconclusive for "${targetName}" — proceeding to spawn: ${err instanceof Error ? err.message : String(err)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
318
359
|
try {
|
|
319
360
|
if (agent === 'mock') {
|
|
320
361
|
// ADR 0014 PR-2 — mock adapter spawns headless. No terminal,
|
package/dist/pi/cue-pump.d.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cue pump — pulls cues queued on the session workflow and injects them into
|
|
3
|
-
*
|
|
2
|
+
* Cue pump — pulls cues queued on the session workflow and injects them into the
|
|
3
|
+
* LIVE Pi agent, then acks them.
|
|
4
4
|
*
|
|
5
5
|
* Pi has no reverse-RPC into a running session from Temporal, so (like the
|
|
6
6
|
* existing adapters) we poll `pendingMessages` and ack via `markDelivered`.
|
|
7
7
|
*
|
|
8
|
+
* ── Injection target: the STABLE `pi` handle, re-resolved per tick (#677) ──
|
|
9
|
+
* Pi 0.78.1's `SessionStartEvent` carries NO `session` field, so in INTERACTIVE
|
|
10
|
+
* mode `PiEventPayload.session` is null → the old `resolveSession` returned null
|
|
11
|
+
* every tick → the interactive Pi conductor NEVER received cues. The fix routes
|
|
12
|
+
* injection through the `pi` ExtensionAPI handle (`pi.sendMessage`), which is
|
|
13
|
+
* always live. Crucially the injector is RE-RESOLVED PER TICK from the surviving
|
|
14
|
+
* module-scope runtime — capturing it once silently dies after an interactive
|
|
15
|
+
* session switch (the runtime's `pi` is repointed on rebind). Headless still works
|
|
16
|
+
* (its `pi` is the real ExtensionAPI too); the legacy `session.sendCustomMessage`
|
|
17
|
+
* path is kept as a feature-detected fallback.
|
|
18
|
+
*
|
|
8
19
|
* Injection follows D10 cue-delivery semantics:
|
|
9
20
|
* - **deliverAs** — operator cue (`msg.isMaestro`, a human steering from the
|
|
10
21
|
* Maestro dashboard) → `'steer'` (interrupt the in-flight turn so the
|
|
@@ -16,44 +27,106 @@
|
|
|
16
27
|
* is a no-op when a turn is already running (the message just queues), so we
|
|
17
28
|
* don't need to race-check the idle state — set it unconditionally.
|
|
18
29
|
*
|
|
30
|
+
* ── Escalation (#677): turn-started → sendUserMessage ──
|
|
31
|
+
* `triggerTurn: true` on `sendMessage` SHOULD wake a cold-idle agent, but if it
|
|
32
|
+
* doesn't (e.g. a Pi regression, or a queued followUp that never drains), the
|
|
33
|
+
* cue sits unprocessed and silently. The pump therefore tracks the last cue it
|
|
34
|
+
* injected via the escalation-eligible `pi.sendMessage` route; on the NEXT tick,
|
|
35
|
+
* if NO turn started since (the runtime's `lastTurnStartAt` is still older than
|
|
36
|
+
* the inject), it re-injects the SAME cue via `pi.sendUserMessage` — a user-role
|
|
37
|
+
* message ALWAYS starts a turn. Escalation fires at most once per cue (it can't
|
|
38
|
+
* loop). The primary route stays `pi.sendMessage` so the `cue` customType +
|
|
39
|
+
* operator-vs-peer steer/followUp semantics are preserved; `sendUserMessage`
|
|
40
|
+
* loses both, so it is fallback-only.
|
|
41
|
+
*
|
|
19
42
|
* Adapted from Pi's `examples/extensions/file-trigger.ts`.
|
|
20
43
|
*/
|
|
21
44
|
import type { Message } from '../types';
|
|
22
|
-
import type { PiAgentSession } from './pi-types';
|
|
45
|
+
import type { ExtensionAPI, PiAgentSession, PiOutboundMessage, PiCustomMessageOptions } from './pi-types';
|
|
23
46
|
/** Source of pending cues + ack — satisfied by `PiWorkflowClient`. */
|
|
24
47
|
export interface CueSource {
|
|
25
48
|
fetchPending(): Promise<Message[]>;
|
|
26
49
|
ackDelivered(messageIds: string[]): Promise<void>;
|
|
27
50
|
}
|
|
28
51
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
52
|
+
* The live cue-injection capability, RE-RESOLVED each tick from the surviving
|
|
53
|
+
* runtime so a session switch never injects through a stale handle. Two routes:
|
|
54
|
+
* - PRIMARY (`pi.sendMessage`): preserves the `cue` customType + steer/followUp
|
|
55
|
+
* operator-vs-peer semantics. Escalation-eligible.
|
|
56
|
+
* - FALLBACK (`session.sendCustomMessage`): legacy path; NOT escalation-eligible.
|
|
57
|
+
*/
|
|
58
|
+
export interface MessageInjector {
|
|
59
|
+
/** Inject one cue (D10 — `cue` customType, steer/followUp + triggerTurn). */
|
|
60
|
+
inject(msg: PiOutboundMessage, opts: PiCustomMessageOptions): void | Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Re-inject the SAME cue as a user-role message (always wakes a turn). Present
|
|
63
|
+
* ONLY on the escalation-eligible `pi.sendMessage` route — its presence IS the
|
|
64
|
+
* "this route can escalate" signal (the legacy session fallback omits it).
|
|
65
|
+
*/
|
|
66
|
+
escalate?(text: string): void | Promise<void>;
|
|
67
|
+
/** Epoch-ms of the last observed `turn_start` (null = none yet) — drives escalation. */
|
|
68
|
+
lastTurnStartAt(): number | null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolves the CURRENT injection capability at tick time. Re-acquired every tick
|
|
72
|
+
* rather than captured once, so a Pi instance rebuild (D11) never injects through
|
|
73
|
+
* a stale `pi`/session. Returns `null` when nothing is attached yet.
|
|
32
74
|
*/
|
|
33
|
-
export type
|
|
75
|
+
export type InjectorResolver = () => MessageInjector | null;
|
|
76
|
+
/** The runtime slice {@link buildPiInjector} reads — satisfied by `PiPlayerRuntime`. */
|
|
77
|
+
export interface InjectorRuntime {
|
|
78
|
+
pi: ExtensionAPI | null;
|
|
79
|
+
session: PiAgentSession | null;
|
|
80
|
+
lastTurnStartAt: number | null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the per-tick {@link MessageInjector} from the live runtime, PREFERRING the
|
|
84
|
+
* stable `pi.sendMessage` handle (interactive root-cause fix, #677) and falling
|
|
85
|
+
* back to `session.sendCustomMessage` only when `pi.sendMessage` is unavailable.
|
|
86
|
+
* Pure + feature-detected (`typeof`) so it's safe whatever Pi build is loaded and
|
|
87
|
+
* unit-testable without a real Pi.
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildPiInjector(rt: InjectorRuntime | null | undefined): MessageInjector | null;
|
|
34
90
|
export interface CuePumpOptions {
|
|
35
91
|
source: CueSource;
|
|
36
|
-
|
|
92
|
+
resolveInjector: InjectorResolver;
|
|
37
93
|
/** Poll interval (ms). */
|
|
38
94
|
intervalMs?: number;
|
|
95
|
+
/** Injected clock (tests). Defaults to `Date.now`. */
|
|
96
|
+
now?: () => number;
|
|
39
97
|
}
|
|
40
98
|
export declare class CuePump {
|
|
41
99
|
private readonly source;
|
|
42
|
-
private readonly
|
|
100
|
+
private readonly resolveInjector;
|
|
43
101
|
private readonly intervalMs;
|
|
102
|
+
private readonly now;
|
|
44
103
|
private timer;
|
|
45
104
|
private draining;
|
|
105
|
+
/**
|
|
106
|
+
* The last cue injected via the escalation-eligible `pi.sendMessage` route,
|
|
107
|
+
* pending a turn-start check on the next tick. Cleared once a turn starts or
|
|
108
|
+
* once escalated (escalate-once invariant).
|
|
109
|
+
*/
|
|
110
|
+
private lastInject;
|
|
46
111
|
constructor(opts: CuePumpOptions);
|
|
47
112
|
start(): void;
|
|
48
113
|
stop(): void;
|
|
49
114
|
/**
|
|
50
|
-
* One poll cycle:
|
|
51
|
-
*
|
|
52
|
-
* overlaps the
|
|
115
|
+
* One poll cycle: (1) escalate a previously-injected cue that never woke a turn,
|
|
116
|
+
* then (2) fetch pending cues, inject each into the live agent, ack the ones
|
|
117
|
+
* successfully injected. Re-entrancy guarded so a slow tick never overlaps the
|
|
118
|
+
* next interval.
|
|
53
119
|
*/
|
|
54
120
|
tick(): Promise<void>;
|
|
55
121
|
/**
|
|
56
|
-
*
|
|
122
|
+
* If a previously sendMessage-injected cue has not been followed by a turn, the
|
|
123
|
+
* `triggerTurn` wake didn't take — re-inject the SAME cue as a user-role message
|
|
124
|
+
* (always starts a turn). Escalates at most once per cue; clears the tracker
|
|
125
|
+
* once a turn is observed.
|
|
126
|
+
*/
|
|
127
|
+
private maybeEscalate;
|
|
128
|
+
/**
|
|
129
|
+
* Inject one cue into the live agent (D10 — see file header). Operator cues
|
|
57
130
|
* `steer` (same-turn priority); peer cues `followUp` (queue). `triggerTurn` is
|
|
58
131
|
* always set: a no-op mid-turn, the required cold-idle wake otherwise.
|
|
59
132
|
*/
|
package/dist/pi/cue-pump.js
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CuePump = void 0;
|
|
4
|
+
exports.buildPiInjector = buildPiInjector;
|
|
5
|
+
/**
|
|
6
|
+
* Build the per-tick {@link MessageInjector} from the live runtime, PREFERRING the
|
|
7
|
+
* stable `pi.sendMessage` handle (interactive root-cause fix, #677) and falling
|
|
8
|
+
* back to `session.sendCustomMessage` only when `pi.sendMessage` is unavailable.
|
|
9
|
+
* Pure + feature-detected (`typeof`) so it's safe whatever Pi build is loaded and
|
|
10
|
+
* unit-testable without a real Pi.
|
|
11
|
+
*/
|
|
12
|
+
function buildPiInjector(rt) {
|
|
13
|
+
if (!rt)
|
|
14
|
+
return null;
|
|
15
|
+
const pi = rt.pi;
|
|
16
|
+
const send = typeof pi?.sendMessage === 'function' ? pi.sendMessage.bind(pi) : null;
|
|
17
|
+
if (send) {
|
|
18
|
+
const sendUser = typeof pi?.sendUserMessage === 'function' ? pi.sendUserMessage.bind(pi) : null;
|
|
19
|
+
return {
|
|
20
|
+
inject: (msg, opts) => send(msg, opts),
|
|
21
|
+
...(sendUser ? { escalate: (text) => sendUser(text) } : {}),
|
|
22
|
+
lastTurnStartAt: () => rt.lastTurnStartAt,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const session = rt.session;
|
|
26
|
+
if (session) {
|
|
27
|
+
return {
|
|
28
|
+
inject: (msg, opts) => session.sendCustomMessage(msg, opts),
|
|
29
|
+
lastTurnStartAt: () => rt.lastTurnStartAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
4
34
|
const DEFAULT_POLL_MS = 1_000;
|
|
5
35
|
const log = (...args) => {
|
|
6
36
|
// eslint-disable-next-line no-console
|
|
@@ -8,14 +38,22 @@ const log = (...args) => {
|
|
|
8
38
|
};
|
|
9
39
|
class CuePump {
|
|
10
40
|
source;
|
|
11
|
-
|
|
41
|
+
resolveInjector;
|
|
12
42
|
intervalMs;
|
|
43
|
+
now;
|
|
13
44
|
timer = null;
|
|
14
45
|
draining = false;
|
|
46
|
+
/**
|
|
47
|
+
* The last cue injected via the escalation-eligible `pi.sendMessage` route,
|
|
48
|
+
* pending a turn-start check on the next tick. Cleared once a turn starts or
|
|
49
|
+
* once escalated (escalate-once invariant).
|
|
50
|
+
*/
|
|
51
|
+
lastInject = null;
|
|
15
52
|
constructor(opts) {
|
|
16
53
|
this.source = opts.source;
|
|
17
|
-
this.
|
|
54
|
+
this.resolveInjector = opts.resolveInjector;
|
|
18
55
|
this.intervalMs = opts.intervalMs ?? DEFAULT_POLL_MS;
|
|
56
|
+
this.now = opts.now ?? Date.now;
|
|
19
57
|
}
|
|
20
58
|
start() {
|
|
21
59
|
if (this.timer)
|
|
@@ -33,28 +71,40 @@ class CuePump {
|
|
|
33
71
|
}
|
|
34
72
|
}
|
|
35
73
|
/**
|
|
36
|
-
* One poll cycle:
|
|
37
|
-
*
|
|
38
|
-
* overlaps the
|
|
74
|
+
* One poll cycle: (1) escalate a previously-injected cue that never woke a turn,
|
|
75
|
+
* then (2) fetch pending cues, inject each into the live agent, ack the ones
|
|
76
|
+
* successfully injected. Re-entrancy guarded so a slow tick never overlaps the
|
|
77
|
+
* next interval.
|
|
39
78
|
*/
|
|
40
79
|
async tick() {
|
|
41
80
|
if (this.draining)
|
|
42
81
|
return;
|
|
43
82
|
this.draining = true;
|
|
44
83
|
try {
|
|
84
|
+
const injector = this.resolveInjector();
|
|
85
|
+
// (1) Escalation check — runs even with no new pending. If the previous
|
|
86
|
+
// tick injected a cue via pi.sendMessage and NO turn has started since, the
|
|
87
|
+
// cue may be sitting in a cold-idle agent's queue unprocessed → re-inject as
|
|
88
|
+
// a user message (which always wakes a turn). Once per cue.
|
|
89
|
+
await this.maybeEscalate(injector);
|
|
45
90
|
const pending = await this.source.fetchPending();
|
|
46
91
|
if (pending.length === 0)
|
|
47
92
|
return;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
93
|
+
if (!injector) {
|
|
94
|
+
// No live injection target yet (no `pi` handle / session) — leave cues
|
|
95
|
+
// queued; next tick retries once an instance attaches/rebinds. Logged so a
|
|
96
|
+
// live bring-up can see cues are HELD (not lost) while waiting to attach.
|
|
97
|
+
log(`no live injector — holding ${pending.length} cue(s) for next tick`);
|
|
51
98
|
return;
|
|
52
99
|
}
|
|
53
100
|
const delivered = [];
|
|
101
|
+
let lastDeliveredText = null;
|
|
54
102
|
for (const msg of pending) {
|
|
103
|
+
const content = msg.from ? `[cue from ${msg.from}] ${msg.text}` : msg.text;
|
|
55
104
|
try {
|
|
56
|
-
await this.injectCue(
|
|
105
|
+
await this.injectCue(injector, msg, content);
|
|
57
106
|
delivered.push(msg.id);
|
|
107
|
+
lastDeliveredText = content;
|
|
58
108
|
}
|
|
59
109
|
catch (err) {
|
|
60
110
|
log(`failed to inject cue ${msg.id}:`, err);
|
|
@@ -63,18 +113,54 @@ class CuePump {
|
|
|
63
113
|
}
|
|
64
114
|
}
|
|
65
115
|
await this.source.ackDelivered(delivered);
|
|
116
|
+
// Track ONLY the LAST cue injected via the escalation-eligible route so the
|
|
117
|
+
// NEXT tick can re-inject it as a user message if no turn started. Tracking
|
|
118
|
+
// just the last is intentional and does NOT drop earlier cues' delivery:
|
|
119
|
+
// every cue in this batch was already injected via pi.sendMessage (queued in
|
|
120
|
+
// Pi), so they ALL drain once any turn starts — escalation only needs to WAKE
|
|
121
|
+
// a turn, and re-injecting one cue as a user message does exactly that. The
|
|
122
|
+
// session-fallback route omits `escalate` → no tracking.
|
|
123
|
+
if (injector.escalate && lastDeliveredText !== null) {
|
|
124
|
+
this.lastInject = { text: lastDeliveredText, injectedAt: this.now(), escalated: false };
|
|
125
|
+
}
|
|
66
126
|
}
|
|
67
127
|
finally {
|
|
68
128
|
this.draining = false;
|
|
69
129
|
}
|
|
70
130
|
}
|
|
71
131
|
/**
|
|
72
|
-
*
|
|
132
|
+
* If a previously sendMessage-injected cue has not been followed by a turn, the
|
|
133
|
+
* `triggerTurn` wake didn't take — re-inject the SAME cue as a user-role message
|
|
134
|
+
* (always starts a turn). Escalates at most once per cue; clears the tracker
|
|
135
|
+
* once a turn is observed.
|
|
136
|
+
*/
|
|
137
|
+
async maybeEscalate(injector) {
|
|
138
|
+
const pending = this.lastInject;
|
|
139
|
+
if (!pending || pending.escalated)
|
|
140
|
+
return;
|
|
141
|
+
if (!injector?.escalate)
|
|
142
|
+
return;
|
|
143
|
+
const turnAt = injector.lastTurnStartAt();
|
|
144
|
+
if (turnAt !== null && turnAt >= pending.injectedAt) {
|
|
145
|
+
// A turn started after the inject → the cue was picked up; stop tracking.
|
|
146
|
+
this.lastInject = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await injector.escalate(pending.text);
|
|
151
|
+
pending.escalated = true;
|
|
152
|
+
log('escalated un-woken cue via sendUserMessage');
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
log('cue escalation failed:', err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Inject one cue into the live agent (D10 — see file header). Operator cues
|
|
73
160
|
* `steer` (same-turn priority); peer cues `followUp` (queue). `triggerTurn` is
|
|
74
161
|
* always set: a no-op mid-turn, the required cold-idle wake otherwise.
|
|
75
162
|
*/
|
|
76
|
-
async injectCue(
|
|
77
|
-
const content = msg.from ? `[cue from ${msg.from}] ${msg.text}` : msg.text;
|
|
163
|
+
async injectCue(injector, msg, content) {
|
|
78
164
|
// LOAD-BEARING Pi-runtime invariant (D10) — confirmed sound through Pi 0.78.x
|
|
79
165
|
// (researcher-cited; a D6 "behaviors-to-revalidate-on-bump" item):
|
|
80
166
|
// peer cue = { deliverAs: 'followUp', triggerTurn: true } → QUEUES; drains
|
|
@@ -87,9 +173,10 @@ class CuePump {
|
|
|
87
173
|
// The guarantee this comment protects: a future Pi version MUST keep followUp
|
|
88
174
|
// non-interrupting AND triggerTurn a no-op-while-busy. If that regresses, peer
|
|
89
175
|
// cues silently become preemptions, defeating operator-vs-peer. Not unit-testable
|
|
90
|
-
// here (the
|
|
91
|
-
// version floor (≥ #2860 + #5115) + a real-Pi mid-turn integration smoke.
|
|
92
|
-
|
|
176
|
+
// here (the injector is mocked) — locked by researcher confirmation + the D6 Pi
|
|
177
|
+
// version floor (≥ #2860 + #5115) + a real-Pi mid-turn integration smoke. The
|
|
178
|
+
// #677 sendUserMessage escalation is the belt-and-suspenders for a missed wake.
|
|
179
|
+
await injector.inject({ customType: 'cue', content, display: true }, { deliverAs: msg.isMaestro ? 'steer' : 'followUp', triggerTurn: true });
|
|
93
180
|
}
|
|
94
181
|
}
|
|
95
182
|
exports.CuePump = CuePump;
|
package/dist/pi/extension.d.ts
CHANGED
|
@@ -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
|
-
},
|
|
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;
|
package/dist/pi/extension.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.
|
|
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
|
-
|
|
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
|
-
*
|
|
103
|
+
* Interactive-session breadcrumb (#645 H4 → reworded for #677).
|
|
99
104
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
307
|
-
|
|
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;
|
package/dist/pi/index.d.ts
CHANGED
|
@@ -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,
|
|
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; } });
|
package/dist/pi/pi-types.d.ts
CHANGED
|
@@ -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>;
|
package/dist/pi/reset-pump.d.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reset pump (3d D14) — polls the session workflow's single-slot
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
/**
|
|
46
|
-
private
|
|
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
|
}
|
package/dist/pi/reset-pump.js
CHANGED
|
@@ -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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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 (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
/**
|
|
59
|
-
async
|
|
60
|
-
|
|
61
|
-
|
|
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;
|
package/dist/server-tools.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/server-tools.js
CHANGED
|
@@ -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),
|
package/dist/server.js
CHANGED
|
@@ -72,7 +72,10 @@ async function main() {
|
|
|
72
72
|
await idleServer.connect(transport);
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
// #676 FIX-1 — resolve config WITH sources so recruit can tell an operator-SET
|
|
76
|
+
// defaultAgent from the built-in 'claude' default. `.config` is the same Config
|
|
77
|
+
// getConfig() returns (behavior-preserving); `.sources.defaultAgent` is the origin.
|
|
78
|
+
const { config, sources } = (0, config_1.getConfigWithSources)();
|
|
76
79
|
const isConductor = process.env[config_1.ENV.CONDUCTOR] === 'true';
|
|
77
80
|
const requestedName = process.env[config_1.ENV.PLAYER_NAME] || '';
|
|
78
81
|
// Conductors use their requested name or fall back to 'conductor'.
|
|
@@ -289,7 +292,7 @@ async function main() {
|
|
|
289
292
|
// the same call. Adding a new tool? Add it once in `server-tools.ts`.
|
|
290
293
|
(0, server_tools_1.registerAllTempoTools)(mcpServer, {
|
|
291
294
|
client, config, getPlayerId, setPlayerId, handle, workflowId,
|
|
292
|
-
ownAgentType, isConductor,
|
|
295
|
+
ownAgentType, defaultAgentSource: sources.defaultAgent, isConductor,
|
|
293
296
|
});
|
|
294
297
|
const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
|
|
295
298
|
// Start message poller — push messages into Claude Code via channel notifications.
|
package/dist/tools/recruit.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Client, WorkflowHandle } from '@temporalio/client';
|
|
2
|
-
import { Config } from '../config';
|
|
2
|
+
import { Config, ConfigSource } from '../config';
|
|
3
3
|
import { AgentType } from '../types';
|
|
4
4
|
import type { HostInfo } from '../types';
|
|
5
5
|
import { type TempoToolDescriptor } from './descriptor';
|
|
@@ -13,7 +13,24 @@ import { type TempoToolDescriptor } from './descriptor';
|
|
|
13
13
|
export interface RegisterRecruitToolDeps {
|
|
14
14
|
listHostsFn?: (client: Client) => Promise<HostInfo[]>;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* #676 FIX-1 — recruit agent precedence: explicit `argAgent` > operator-SET
|
|
18
|
+
* `configDefault` > `ownAgentType` (this player's mirror-fallback). The
|
|
19
|
+
* `defaultAgentSource` distinguishes an operator-set default (origin
|
|
20
|
+
* flag/env/config/temporal-cli) from the built-in 'claude' default (source
|
|
21
|
+
* 'default') / truly-unset ('none') — only an operator-set default wins over the
|
|
22
|
+
* mirror, so a copilot/pi conductor recruits its own kind by default. Pure +
|
|
23
|
+
* exported for unit testing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveRecruitAgent(argAgent: AgentType | undefined, configDefault: AgentType, defaultAgentSource: ConfigSource | undefined, ownAgentType: AgentType): AgentType;
|
|
26
|
+
export declare function buildRecruitTool(client: Client, config: Config, getPlayerId: () => string, handle: WorkflowHandle, ownAgentType?: AgentType,
|
|
27
|
+
/**
|
|
28
|
+
* #676 FIX-1 — the SOURCE of `config.defaultAgent` (from getConfigWithSources),
|
|
29
|
+
* used to distinguish an operator-SET default from the built-in 'claude'
|
|
30
|
+
* default (source 'default'). Undefined → treated as not-operator-set →
|
|
31
|
+
* recruit falls back to `ownAgentType` (preserves the pre-FIX-1 mirror).
|
|
32
|
+
*/
|
|
33
|
+
defaultAgentSource?: ConfigSource, deps?: RegisterRecruitToolDeps): TempoToolDescriptor;
|
|
17
34
|
/**
|
|
18
35
|
* Given a host liveness+profile snapshot, validate that `targetHost` is
|
|
19
36
|
* (a) known, (b) recruit-ready, and (c) advertises support for
|
package/dist/tools/recruit.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.resolveRecruitAgent = resolveRecruitAgent;
|
|
36
37
|
exports.buildRecruitTool = buildRecruitTool;
|
|
37
38
|
exports.checkHostPreflight = checkHostPreflight;
|
|
38
39
|
exports.nearestHostname = nearestHostname;
|
|
@@ -78,7 +79,30 @@ function hasOpencodeOnPath() {
|
|
|
78
79
|
return false;
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
|
-
|
|
82
|
+
/**
|
|
83
|
+
* #676 FIX-1 — recruit agent precedence: explicit `argAgent` > operator-SET
|
|
84
|
+
* `configDefault` > `ownAgentType` (this player's mirror-fallback). The
|
|
85
|
+
* `defaultAgentSource` distinguishes an operator-set default (origin
|
|
86
|
+
* flag/env/config/temporal-cli) from the built-in 'claude' default (source
|
|
87
|
+
* 'default') / truly-unset ('none') — only an operator-set default wins over the
|
|
88
|
+
* mirror, so a copilot/pi conductor recruits its own kind by default. Pure +
|
|
89
|
+
* exported for unit testing.
|
|
90
|
+
*/
|
|
91
|
+
function resolveRecruitAgent(argAgent, configDefault, defaultAgentSource, ownAgentType) {
|
|
92
|
+
if (argAgent)
|
|
93
|
+
return argAgent;
|
|
94
|
+
const operatorSet = !!defaultAgentSource
|
|
95
|
+
&& ['flag', 'env', 'config', 'temporal-cli'].includes(defaultAgentSource);
|
|
96
|
+
return operatorSet ? configDefault : ownAgentType;
|
|
97
|
+
}
|
|
98
|
+
function buildRecruitTool(client, config, getPlayerId, handle, ownAgentType = 'claude',
|
|
99
|
+
/**
|
|
100
|
+
* #676 FIX-1 — the SOURCE of `config.defaultAgent` (from getConfigWithSources),
|
|
101
|
+
* used to distinguish an operator-SET default from the built-in 'claude'
|
|
102
|
+
* default (source 'default'). Undefined → treated as not-operator-set →
|
|
103
|
+
* recruit falls back to `ownAgentType` (preserves the pre-FIX-1 mirror).
|
|
104
|
+
*/
|
|
105
|
+
defaultAgentSource, deps = {}) {
|
|
82
106
|
// Lazy default — only imports utils/hosts when actually called, so the
|
|
83
107
|
// MCP server's module load graph doesn't drag the whole join layer
|
|
84
108
|
// into every consumer at import time.
|
|
@@ -133,7 +157,7 @@ function buildRecruitTool(client, config, getPlayerId, handle, ownAgentType = 'c
|
|
|
133
157
|
handler: async (args) => {
|
|
134
158
|
const { workDir, name, initialMessage } = args;
|
|
135
159
|
const isConductor = args.conductor === true;
|
|
136
|
-
const agent = args.agent
|
|
160
|
+
const agent = resolveRecruitAgent(args.agent, config.defaultAgent, defaultAgentSource, ownAgentType);
|
|
137
161
|
const model = args.model;
|
|
138
162
|
const agentTypeName = args.type;
|
|
139
163
|
const systemPrompt = args.systemPrompt;
|