pactive 0.0.3-alpha.1 → 0.0.4-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.opcteam-plugin/plugin.json +8 -4
- package/BREAKING-CHANGES.md +14 -3
- package/README.md +84 -10
- package/bin/pactive.mjs +650 -18
- package/dsh/index.js +170 -14
- package/mcp/tools/definitions.mjs +159 -13
- package/package.json +1 -1
- package/pi/extension.ts +191 -15
- package/pi/skills/pactive/SKILL.md +16 -2
- package/skills/pactive/SKILL.md +16 -2
- package/src/core/assistants.mjs +237 -0
- package/src/core/config.mjs +40 -3
- package/src/core/fleet.mjs +80 -0
- package/src/core/guard.mjs +117 -32
- package/src/core/idle.mjs +11 -8
- package/src/core/index.mjs +3 -0
- package/src/core/scheduler.mjs +10 -2
- package/src/core/state.mjs +134 -12
- package/src/core/tick.mjs +128 -0
- package/src/hosts/herdr/fleet-view.mjs +31 -0
- package/src/hosts/herdr/index.mjs +429 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { defaultAssistantId } from './assistants.mjs';
|
|
2
|
+
import { reserveTrigger } from './guard.mjs';
|
|
3
|
+
import { evaluateIdle } from './idle.mjs';
|
|
4
|
+
import { ledger } from './lineage.mjs';
|
|
5
|
+
|
|
6
|
+
/** Reasons that mean "this really was going to fire but the throttle held it back". */
|
|
7
|
+
export const THROTTLE_REASONS = ['quiet', 'hourly-limit', 'global-limit', 'min-interval', 'cooldown'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One drive round: due tasks + one idle sweep per enabled assistant,结果落 ledger。
|
|
11
|
+
*
|
|
12
|
+
* This is the minimal closed loop before a daemon exists: `pactive tick` runs it once, so the
|
|
13
|
+
* fleet is testable unattended. A blocked task stays active (retried next tick) — losing a
|
|
14
|
+
* reminder because the guard was closed would be silent data loss.
|
|
15
|
+
*
|
|
16
|
+
* ledger events (append-only, per assistantId): `triggered` + `delivered`, or `blocked`+reason.
|
|
17
|
+
*/
|
|
18
|
+
export function runTick(state, now = Date.now(), options = {}) {
|
|
19
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
20
|
+
const ledgerFile = typeof options.ledgerFile === 'string' && options.ledgerFile ? options.ledgerFile : null;
|
|
21
|
+
const only = typeof options.assistantId === 'string' && options.assistantId !== '' ? options.assistantId : null;
|
|
22
|
+
const record = (event) => {
|
|
23
|
+
if (ledgerFile) ledger.append(ledgerFile, event);
|
|
24
|
+
return event;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const delivered = [];
|
|
28
|
+
const blocked = [];
|
|
29
|
+
const fallbackId = defaultAssistantId(state.identity?.ownerKey ?? '');
|
|
30
|
+
const table = state.assistants && typeof state.assistants === 'object' ? state.assistants : {};
|
|
31
|
+
const tasks = Array.isArray(state.tasks) ? state.tasks : [];
|
|
32
|
+
|
|
33
|
+
const queued = [];
|
|
34
|
+
const unreachable = [];
|
|
35
|
+
/**
|
|
36
|
+
* Deliver one prompt and let the receipt decide the ledger event (contract §2.2):
|
|
37
|
+
* a bound pane that only *admitted* the text is `queued`, never `delivered` (I77).
|
|
38
|
+
*/
|
|
39
|
+
const deliverOne = (base) => {
|
|
40
|
+
const assistant = table[base.assistantId] ?? null;
|
|
41
|
+
const pane = assistant?.pane ?? null;
|
|
42
|
+
if (pane && typeof options.paneDeliver === 'function') {
|
|
43
|
+
const receipt = options.paneDeliver({ ...base, pane }) ?? { state: 'queued', reason: 'no-receipt' };
|
|
44
|
+
const common = { assistantId: base.assistantId, paneId: pane.paneId, agentName: pane.agentName ?? null, ...base };
|
|
45
|
+
if (receipt.state === 'accepted') {
|
|
46
|
+
return delivered.push(record({
|
|
47
|
+
event: 'delivered',
|
|
48
|
+
...common,
|
|
49
|
+
surface: 'herdr',
|
|
50
|
+
status: receipt.status ?? null,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
if (receipt.state === 'unreachable') {
|
|
54
|
+
// The pane is gone: drop the binding so the next `fleet up` can rebuild it. The
|
|
55
|
+
// assistant, its tasks and its history stay (I75).
|
|
56
|
+
assistant.pane = null;
|
|
57
|
+
return unreachable.push(record({ event: 'unreachable', ...common, reason: receipt.reason ?? 'unreachable' }));
|
|
58
|
+
}
|
|
59
|
+
return queued.push(record({ event: 'queued', ...common, reason: receipt.reason ?? 'queued' }));
|
|
60
|
+
}
|
|
61
|
+
// No pane: the in-process/outbox path (unchanged), but `paneId` is always present.
|
|
62
|
+
return delivered.push(record({ event: 'delivered', paneId: null, ...base }));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ① Due tasks (explicit user reminders): min-interval/cooldown do not apply, the user asked.
|
|
66
|
+
for (const task of tasks) {
|
|
67
|
+
if (!task || typeof task !== 'object' || task.active === false) continue;
|
|
68
|
+
if (!Number.isFinite(task.dueAt) || task.dueAt > ts) continue;
|
|
69
|
+
const assistantId = typeof task.assistantId === 'string' && task.assistantId ? task.assistantId : fallbackId;
|
|
70
|
+
if (only && assistantId !== only) continue;
|
|
71
|
+
const res = reserveTrigger(state, ts, assistantId, { ignoreEpoch: true, skipIntervalGates: true });
|
|
72
|
+
if (res.reserved) {
|
|
73
|
+
task.lastRunAt = ts;
|
|
74
|
+
if (task.kind === 'every') task.dueAt = ts + (Number.isFinite(task.everyMs) ? task.everyMs : 0);
|
|
75
|
+
else task.active = false;
|
|
76
|
+
const assistant = table[assistantId];
|
|
77
|
+
if (assistant) {
|
|
78
|
+
assistant.lastFiredAt = ts;
|
|
79
|
+
assistant.firedCount = (Number.isFinite(assistant.firedCount) ? assistant.firedCount : 0) + 1;
|
|
80
|
+
}
|
|
81
|
+
record({ event: 'triggered', assistantId, taskId: task.id, taskKind: task.kind, source: 'tick' });
|
|
82
|
+
deliverOne({
|
|
83
|
+
assistantId,
|
|
84
|
+
taskId: task.id,
|
|
85
|
+
taskKind: task.kind,
|
|
86
|
+
source: 'tick',
|
|
87
|
+
prompt: task.prompt ?? null,
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
blocked.push(record({ event: 'blocked', assistantId, taskId: task.id, source: 'tick', reason: res.reason }));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ② Idle sweep: only enabled assistants, only throttle reasons are recorded (no ledger spam).
|
|
95
|
+
for (const [assistantId, assistant] of Object.entries(table)) {
|
|
96
|
+
if (!assistant || assistant.enabled !== true) continue;
|
|
97
|
+
if (only && assistantId !== only) continue;
|
|
98
|
+
const result = evaluateIdle(state, ts, {}, assistantId);
|
|
99
|
+
if (result.idle) {
|
|
100
|
+
const res = reserveTrigger(state, ts, assistantId);
|
|
101
|
+
if (res.reserved) {
|
|
102
|
+
assistant.lastFiredAt = ts;
|
|
103
|
+
assistant.firedCount = (Number.isFinite(assistant.firedCount) ? assistant.firedCount : 0) + 1;
|
|
104
|
+
record({ event: 'triggered', assistantId, source: 'tick', reason: 'idle' });
|
|
105
|
+
deliverOne({ assistantId, source: 'tick', reason: 'idle' });
|
|
106
|
+
} else {
|
|
107
|
+
blocked.push(record({ event: 'blocked', assistantId, source: 'tick', reason: res.reason }));
|
|
108
|
+
}
|
|
109
|
+
} else if (THROTTLE_REASONS.includes(result.reason)) {
|
|
110
|
+
blocked.push(record({ event: 'blocked', assistantId, source: 'tick', reason: result.reason }));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
now: ts,
|
|
116
|
+
assistantId: only,
|
|
117
|
+
assistants: Object.keys(table).length,
|
|
118
|
+
delivered: delivered.length,
|
|
119
|
+
blocked: blocked.length,
|
|
120
|
+
queued: queued.length,
|
|
121
|
+
unreachable: unreachable.length,
|
|
122
|
+
deliveries: delivered,
|
|
123
|
+
queued_events: queued,
|
|
124
|
+
unreachable_events: unreachable,
|
|
125
|
+
blocks: blocked,
|
|
126
|
+
pending: tasks.filter((t) => t && t.active !== false).length,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The fleet view shared by every tool surface (MCP / Pi / DSH) and the CLI.
|
|
2
|
+
//
|
|
3
|
+
// It is a thin join: the *binding* comes from pactive state (I76), the *status* comes from herdr
|
|
4
|
+
// (`agent list`). When herdr cannot be asked, the status is `null` → rendered as "unobserved";
|
|
5
|
+
// it is never guessed (I78).
|
|
6
|
+
import * as herdr from './index.mjs';
|
|
7
|
+
|
|
8
|
+
/** herdr `agent list` → `{available, ok, reason, map: agentName → agent_status}`. */
|
|
9
|
+
export function herdrStatus(env = process.env) {
|
|
10
|
+
const probe = herdr.available({ env });
|
|
11
|
+
const map = new Map();
|
|
12
|
+
if (!probe.ok) return { available: false, ok: false, reason: probe.reason, map };
|
|
13
|
+
const listed = herdr.list({ env });
|
|
14
|
+
if (listed.ok) for (const agent of listed.agents) if (agent.agentName) map.set(agent.agentName, agent.status);
|
|
15
|
+
return { available: true, ok: listed.ok, reason: listed.ok ? null : listed.reason, map };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `{ok, count, herdr, rows}` — one row per assistant: identity ↔ pane ↔ status ↔ last delivery. */
|
|
19
|
+
export function fleetPayload(core, state, paths, env = process.env) {
|
|
20
|
+
const status = herdrStatus(env);
|
|
21
|
+
const rows = core.fleet.view(state, {
|
|
22
|
+
ledgerFile: paths.ledgerFile,
|
|
23
|
+
statusOf: (pane) => (pane.agentName ? status.map.get(pane.agentName) ?? null : null),
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
count: rows.length,
|
|
28
|
+
herdr: { available: status.available, reason: status.reason },
|
|
29
|
+
rows,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Herdr host adapter (contract §2.1 / §2.2, I74–I79).
|
|
2
|
+
//
|
|
3
|
+
// Herdr is *not* a host we re-implement: it is the multiplexer we reuse. Nothing here creates a
|
|
4
|
+
// PTY, a tab layout or a renderer (I74) — every call is a `herdr` CLI invocation whose argv we
|
|
5
|
+
// only assemble, and every result is read back from herdr's own JSON envelope.
|
|
6
|
+
//
|
|
7
|
+
// Three rules this file exists to enforce:
|
|
8
|
+
// • I78 — herdr absent is an explicit refusal with a concrete reason, never a silent fake start.
|
|
9
|
+
// • I77 — delivery is judged on the *agent* surface (`agent prompt` + `agent get` state), never
|
|
10
|
+
// on screen text (`pane read | grep` is not evidence).
|
|
11
|
+
// • I79 — an assistant owns at most one live pane; `spawn` reuses an existing binding.
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
import { makeResolver } from '../shared/index.mjs';
|
|
18
|
+
|
|
19
|
+
export const host = 'herdr';
|
|
20
|
+
export const MODE = 'multiplexer';
|
|
21
|
+
export const MIN_VERSION = '0.9';
|
|
22
|
+
|
|
23
|
+
/** `herdr agent start --kind` values this adapter knows how to launch (§2.1). */
|
|
24
|
+
export const AGENT_KINDS = {
|
|
25
|
+
pi: 'pi',
|
|
26
|
+
claude: 'claude',
|
|
27
|
+
codex: 'codex',
|
|
28
|
+
gemini: 'gemini',
|
|
29
|
+
cursor: 'cursor',
|
|
30
|
+
opencode: 'opencode',
|
|
31
|
+
qwen: 'qwen',
|
|
32
|
+
kimi: 'kimi',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Hosts herdr cannot recognise as an agent → degraded to `pane run` + a pane-only binding (I77). */
|
|
36
|
+
export const PANE_ONLY_HOSTS = ['opcteam', 'dsh'];
|
|
37
|
+
|
|
38
|
+
/** Placeholder for the pane id that only exists after the split/tab step has run. */
|
|
39
|
+
export const PANE_TOKEN = '<pane_id>';
|
|
40
|
+
|
|
41
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
42
|
+
|
|
43
|
+
/** `PACTIVE_HERDR_BIN` → PATH `herdr`; throws HostMissingError (exit 127) like every other host. */
|
|
44
|
+
export const resolveExec = makeResolver(host, 'herdr');
|
|
45
|
+
|
|
46
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
47
|
+
/** Grace on top of herdr's own `--timeout` so herdr's verdict wins over the process kill. */
|
|
48
|
+
const PROCESS_GRACE_MS = 5000;
|
|
49
|
+
|
|
50
|
+
function timeoutFrom(env) {
|
|
51
|
+
const raw = Number(env?.PACTIVE_HERDR_TIMEOUT_MS);
|
|
52
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseEnvelope(text) {
|
|
56
|
+
const trimmed = String(text ?? '').trim();
|
|
57
|
+
if (trimmed === '') return null;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(trimmed);
|
|
60
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** I30: a child process must not inherit credential-shaped env (`*_API_KEY`, `*_TOKEN`, …). */
|
|
67
|
+
const CREDENTIAL_ENV = /(^|_)(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?|PRIVATE_?KEY)($|_)/i;
|
|
68
|
+
|
|
69
|
+
export function childEnv(env = process.env) {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const [key, value] of Object.entries(env)) {
|
|
72
|
+
if (!CREDENTIAL_ENV.test(key)) out[key] = value;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run one herdr CLI call. Success/failure comes from the exit status; the payload (or the error
|
|
79
|
+
* code herdr itself reports) is read from the JSON envelope it prints.
|
|
80
|
+
*/
|
|
81
|
+
export function run(exec, argv, ctx = {}) {
|
|
82
|
+
const env = childEnv(ctx.env ?? process.env);
|
|
83
|
+
const res = spawnSync(exec, argv, {
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
env,
|
|
86
|
+
timeout: ctx.timeoutMs ?? timeoutFrom(env),
|
|
87
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
88
|
+
});
|
|
89
|
+
const ok = res.status === 0;
|
|
90
|
+
const envelope = parseEnvelope(ok ? res.stdout : res.stderr) ?? parseEnvelope(res.stdout);
|
|
91
|
+
return {
|
|
92
|
+
ok,
|
|
93
|
+
code: res.status ?? 1,
|
|
94
|
+
argv,
|
|
95
|
+
result: envelope && envelope.result && typeof envelope.result === 'object' ? envelope.result : null,
|
|
96
|
+
error: envelope && envelope.error && typeof envelope.error === 'object' ? envelope.error : null,
|
|
97
|
+
reason: envelope?.error?.code ?? null,
|
|
98
|
+
stderr: String(res.stderr ?? '').trim(),
|
|
99
|
+
stdout: String(res.stdout ?? '').trim(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** I78: two independent preconditions, each with its own concrete reason. */
|
|
104
|
+
export function available(ctx = {}) {
|
|
105
|
+
const env = ctx.env ?? process.env;
|
|
106
|
+
const reasons = [];
|
|
107
|
+
if (env.HERDR_ENV !== '1') {
|
|
108
|
+
reasons.push({
|
|
109
|
+
code: 'HERDR_ENV_MISSING',
|
|
110
|
+
reason: `HERDR_ENV is not 1 (got ${env.HERDR_ENV === undefined ? 'unset' : JSON.stringify(env.HERDR_ENV)}) — pactive is not running inside a herdr pane`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
let exec = null;
|
|
114
|
+
try {
|
|
115
|
+
exec = resolveExec({ env });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
reasons.push({ code: err.code ?? 'HERDR_BIN_MISSING', reason: err.message });
|
|
118
|
+
}
|
|
119
|
+
const ok = reasons.length === 0;
|
|
120
|
+
return {
|
|
121
|
+
ok,
|
|
122
|
+
exec,
|
|
123
|
+
reasons,
|
|
124
|
+
reason: ok ? null : reasons.map((r) => r.reason).join('; '),
|
|
125
|
+
hint: 'daemon + outbox 仍可投递(pactive tick / pactive schedule 不依赖 herdr)',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Agent names must match `[a-z][a-z0-9_-]{0,31}` — a Chinese assistant name is not a valid agent
|
|
131
|
+
* name. The digest covers the *whole* assistantId: hashing only the `#owner` half would give every
|
|
132
|
+
* assistant of one owner the same pane name (two assistants → one agent, I79).
|
|
133
|
+
*/
|
|
134
|
+
export function agentNameFor(assistantId) {
|
|
135
|
+
const digest = crypto.createHash('sha256').update(String(assistantId ?? '')).digest('hex').slice(0, 12);
|
|
136
|
+
return `pactive-${digest}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function kindFor(hostKind) {
|
|
140
|
+
return AGENT_KINDS[hostKind] ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isPaneOnly(hostKind) {
|
|
144
|
+
return PANE_ONLY_HOSTS.includes(hostKind) || kindFor(hostKind) === null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function shellQuote(text) {
|
|
148
|
+
return /^[A-Za-z0-9_./:@%+=-]+$/.test(text) ? text : `'${String(text).replace(/'/g, `'\\''`)}'`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The degraded launch command for a host herdr has no agent kind for (§2.1 last paragraph). */
|
|
152
|
+
export function launchCommand(hostKind) {
|
|
153
|
+
return `${shellQuote(process.execPath)} ${shellQuote(path.join(PKG_ROOT, 'bin', 'pactive.mjs'))} ${hostKind}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The exact herdr argv `spawn` would run — this *is* the `--dry-run` output (zero side effects).
|
|
158
|
+
* A step marked `usesPaneId` gets PANE_TOKEN replaced with the real pane id at execution time.
|
|
159
|
+
*/
|
|
160
|
+
export function planSpawn(spec = {}) {
|
|
161
|
+
const assistantId = spec.assistantId ?? '';
|
|
162
|
+
const hostKind = spec.host ?? 'pi';
|
|
163
|
+
const cwd = spec.cwd ?? process.cwd();
|
|
164
|
+
const tab = spec.tab ?? null;
|
|
165
|
+
const direction = spec.direction === 'down' ? 'down' : 'right';
|
|
166
|
+
const agentName = agentNameFor(assistantId);
|
|
167
|
+
const kind = kindFor(hostKind);
|
|
168
|
+
const steps = [{ argv: null, usesPaneId: false }];
|
|
169
|
+
|
|
170
|
+
if (tab) {
|
|
171
|
+
steps[0].argv = ['tab', 'create', '--cwd', cwd, '--label', String(tab)];
|
|
172
|
+
} else {
|
|
173
|
+
steps[0].argv = ['pane', 'split', '--current', '--direction', direction, '--cwd', cwd, '--no-focus'];
|
|
174
|
+
}
|
|
175
|
+
steps.push({
|
|
176
|
+
argv: kind
|
|
177
|
+
? ['agent', 'start', agentName, '--kind', kind, '--pane', PANE_TOKEN]
|
|
178
|
+
: ['pane', 'run', PANE_TOKEN, launchCommand(hostKind)],
|
|
179
|
+
usesPaneId: true,
|
|
180
|
+
});
|
|
181
|
+
return { assistantId, host: hostKind, agentName, kind, surface: kind ? 'agent' : 'pane', tab, cwd, steps };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function paneIdFrom(step) {
|
|
185
|
+
const result = step.result ?? {};
|
|
186
|
+
return (
|
|
187
|
+
result.pane?.pane_id ?? result.root_pane?.pane_id ?? result.pane_id ?? null
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create the pane and start the agent/pane command. Idempotency (I79) lives one level up in
|
|
193
|
+
* `fleet up`: an assistant that already has a live binding never reaches this function.
|
|
194
|
+
*/
|
|
195
|
+
export function spawn(spec = {}, ctx = {}) {
|
|
196
|
+
const probe = available(ctx);
|
|
197
|
+
if (!probe.ok) return { ok: false, state: 'unreachable', reason: probe.reason, reasons: probe.reasons };
|
|
198
|
+
const env = ctx.env ?? process.env;
|
|
199
|
+
const plan = planSpawn(spec);
|
|
200
|
+
const commands = [];
|
|
201
|
+
|
|
202
|
+
const first = run(probe.exec, plan.steps[0].argv, { env, timeoutMs: ctx.timeoutMs });
|
|
203
|
+
commands.push({ argv: plan.steps[0].argv, ok: first.ok, code: first.code, reason: first.reason });
|
|
204
|
+
if (!first.ok) {
|
|
205
|
+
return { ok: false, state: 'error', reason: first.reason ?? first.stderr ?? 'pane-create-failed', plan, commands };
|
|
206
|
+
}
|
|
207
|
+
const paneId = paneIdFrom(first);
|
|
208
|
+
if (!paneId) {
|
|
209
|
+
return { ok: false, state: 'error', reason: 'pane-id-missing', plan, commands };
|
|
210
|
+
}
|
|
211
|
+
const tabId = first.result?.tab?.tab_id ?? first.result?.pane?.tab_id ?? null;
|
|
212
|
+
|
|
213
|
+
const argv = plan.steps[1].argv.map((part) => (part === PANE_TOKEN ? paneId : part));
|
|
214
|
+
const second = run(probe.exec, argv, { env, timeoutMs: ctx.timeoutMs });
|
|
215
|
+
commands.push({ argv, ok: second.ok, code: second.code, reason: second.reason });
|
|
216
|
+
if (!second.ok && second.reason === 'agent_name_taken') {
|
|
217
|
+
// The name is already live: a previous run left the agent running, or the state lost the
|
|
218
|
+
// binding. Reuse that pane instead of failing (I79: one assistant, one pane — never two).
|
|
219
|
+
const adopted = getAgent(plan.agentName, { env });
|
|
220
|
+
if (adopted.ok && adopted.paneId) {
|
|
221
|
+
run(probe.exec, ['pane', 'close', paneId], { env, timeoutMs: ctx.timeoutMs });
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
state: 'adopted',
|
|
225
|
+
plan,
|
|
226
|
+
commands,
|
|
227
|
+
binding: {
|
|
228
|
+
paneId: adopted.paneId,
|
|
229
|
+
agentName: plan.agentName,
|
|
230
|
+
host: plan.host,
|
|
231
|
+
surface: 'agent',
|
|
232
|
+
tabId: null,
|
|
233
|
+
boundAt: Date.now(),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!second.ok) {
|
|
239
|
+
// The pane exists but carries no agent: close it again so a failed start leaves no orphan pane.
|
|
240
|
+
run(probe.exec, ['pane', 'close', paneId], { env, timeoutMs: ctx.timeoutMs });
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
state: 'error',
|
|
244
|
+
reason: second.reason ?? second.stderr ?? 'agent-start-failed',
|
|
245
|
+
paneId,
|
|
246
|
+
plan,
|
|
247
|
+
commands,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
state: 'bound',
|
|
254
|
+
plan,
|
|
255
|
+
commands,
|
|
256
|
+
binding: {
|
|
257
|
+
paneId,
|
|
258
|
+
agentName: plan.surface === 'agent' ? plan.agentName : null,
|
|
259
|
+
host: plan.host,
|
|
260
|
+
surface: plan.surface,
|
|
261
|
+
tabId,
|
|
262
|
+
boundAt: Date.now(),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** `herdr agent list` → `[{paneId, agentName, status, cwd}]` (I76: 1:1 with `fleet ls`). */
|
|
268
|
+
export function list(ctx = {}) {
|
|
269
|
+
const probe = available(ctx);
|
|
270
|
+
if (!probe.ok) return { ok: false, agents: [], reason: probe.reason };
|
|
271
|
+
const res = run(probe.exec, ['agent', 'list'], { env: ctx.env ?? process.env });
|
|
272
|
+
if (!res.ok) return { ok: false, agents: [], reason: res.reason ?? res.stderr ?? 'agent-list-failed' };
|
|
273
|
+
const agents = Array.isArray(res.result?.agents) ? res.result.agents : [];
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
agents: agents
|
|
277
|
+
.filter((a) => a && typeof a === 'object')
|
|
278
|
+
.map((a) => ({
|
|
279
|
+
paneId: a.pane_id ?? null,
|
|
280
|
+
agentName: typeof a.name === 'string' && a.name !== '' ? a.name : null,
|
|
281
|
+
status: a.agent_status ?? 'unknown',
|
|
282
|
+
cwd: a.cwd ?? null,
|
|
283
|
+
})),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** One agent's live state (`agent get`) — the observation channel of §2.2/I77. */
|
|
288
|
+
export function getAgent(target, ctx = {}) {
|
|
289
|
+
const probe = available(ctx);
|
|
290
|
+
if (!probe.ok) return { ok: false, state: 'unreachable', reason: probe.reason };
|
|
291
|
+
const res = run(probe.exec, ['agent', 'get', target], { env: ctx.env ?? process.env });
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
const gone = res.reason === 'agent_not_found' || res.reason === 'pane_not_found';
|
|
294
|
+
return { ok: false, state: gone ? 'unreachable' : 'error', reason: res.reason ?? res.stderr ?? 'agent-get-failed' };
|
|
295
|
+
}
|
|
296
|
+
const agent = res.result?.agent ?? res.result ?? {};
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
state: 'observed',
|
|
300
|
+
status: agent.agent_status ?? 'unknown',
|
|
301
|
+
paneId: agent.pane_id ?? null,
|
|
302
|
+
cwd: agent.cwd ?? null,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A pane still exists? (`pane get`) */
|
|
307
|
+
export function paneAlive(paneId, ctx = {}) {
|
|
308
|
+
const probe = available(ctx);
|
|
309
|
+
if (!probe.ok) return { ok: false, alive: null, reason: probe.reason };
|
|
310
|
+
const res = run(probe.exec, ['pane', 'get', paneId], { env: ctx.env ?? process.env });
|
|
311
|
+
if (res.ok) return { ok: true, alive: true };
|
|
312
|
+
if (res.reason === 'pane_not_found') return { ok: true, alive: false, reason: 'pane_not_found' };
|
|
313
|
+
return { ok: false, alive: null, reason: res.reason ?? res.stderr ?? 'pane-get-failed' };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const UNREACHABLE_REASONS = new Set(['agent_not_found', 'pane_not_found', 'agent_exited', 'pane_closed']);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Deliver one prompt (§2.2).
|
|
320
|
+
*
|
|
321
|
+
* `delivered` needs BOTH: the command returned ok AND `agent get` reported `working|blocked`
|
|
322
|
+
* (herdr's own states — we do not invent a vocabulary). Anything weaker is `queued` and says so;
|
|
323
|
+
* a vanished pane/agent is `unreachable` so the caller can drop the binding.
|
|
324
|
+
*/
|
|
325
|
+
export function deliver(spec = {}, ctx = {}) {
|
|
326
|
+
const env = ctx.env ?? process.env;
|
|
327
|
+
const probe = available(ctx);
|
|
328
|
+
if (!probe.ok) return { ok: false, state: 'unreachable', reason: probe.reason, at: Date.now() };
|
|
329
|
+
|
|
330
|
+
const text = String(spec.text ?? '');
|
|
331
|
+
|
|
332
|
+
// Degraded pane surface: `pane run` only acknowledges *admission*. There is no agent state to
|
|
333
|
+
// observe, so we must not call it delivered (I77) — the caller falls back to the outbox.
|
|
334
|
+
if (!spec.agentName) {
|
|
335
|
+
if (!spec.paneId) return { ok: false, state: 'unreachable', reason: 'no-agent-and-no-pane', at: Date.now() };
|
|
336
|
+
const alive = paneAlive(spec.paneId, { env });
|
|
337
|
+
if (alive.alive === false) return { ok: false, state: 'unreachable', reason: 'pane_not_found', paneId: spec.paneId, at: Date.now() };
|
|
338
|
+
const res = run(probe.exec, ['pane', 'run', spec.paneId, text], { env, timeoutMs: ctx.timeoutMs });
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
const state5 = UNREACHABLE_REASONS.has(res.reason) ? 'unreachable' : 'queued';
|
|
341
|
+
return { ok: false, state: state5, reason: res.reason ?? res.stderr ?? 'pane-run-failed', paneId: spec.paneId, at: Date.now() };
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
state: 'queued',
|
|
346
|
+
reason: 'pane-surface-unobservable',
|
|
347
|
+
paneId: spec.paneId,
|
|
348
|
+
admitted: true,
|
|
349
|
+
at: Date.now(),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const before = getAgent(spec.agentName, { env });
|
|
354
|
+
if (before.state === 'unreachable') {
|
|
355
|
+
return { ok: false, state: 'unreachable', reason: before.reason, agentName: spec.agentName, at: Date.now() };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Real herdr rejects `--timeout` without `--wait` ("--timeout requires --wait"), and `--wait`
|
|
359
|
+
// *is* the activity gate we need: it returns `agent_prompt_stalled` unless herdr itself observed
|
|
360
|
+
// `working`/`blocked` within 5s (§2.2: use herdr's semantics, do not invent our own).
|
|
361
|
+
const timeoutMs = Number.isFinite(spec.timeoutMs) && spec.timeoutMs > 0 ? spec.timeoutMs : timeoutFrom(env);
|
|
362
|
+
const sent = run(
|
|
363
|
+
probe.exec,
|
|
364
|
+
['agent', 'prompt', spec.agentName, text, '--wait', '--timeout', String(timeoutMs)],
|
|
365
|
+
{ env, timeoutMs: timeoutMs + PROCESS_GRACE_MS },
|
|
366
|
+
);
|
|
367
|
+
const after = getAgent(spec.agentName, { env });
|
|
368
|
+
|
|
369
|
+
if (sent.ok) {
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
state: 'accepted',
|
|
373
|
+
reason: after.status === 'blocked' ? 'agent-blocked' : 'agent-activity-observed',
|
|
374
|
+
status: after.status ?? null,
|
|
375
|
+
agentName: spec.agentName,
|
|
376
|
+
paneId: after.paneId ?? spec.paneId ?? null,
|
|
377
|
+
at: Date.now(),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// `timeout` is the ambiguous verdict (herdr: "a timeout does not prove the prompt was never
|
|
381
|
+
// delivered") — say so instead of pretending either way.
|
|
382
|
+
const ambiguous = sent.reason === 'timeout';
|
|
383
|
+
return {
|
|
384
|
+
ok: false,
|
|
385
|
+
state: UNREACHABLE_REASONS.has(sent.reason) ? 'unreachable' : 'queued',
|
|
386
|
+
reason: sent.reason ?? sent.stderr ?? 'agent-prompt-failed',
|
|
387
|
+
ambiguous,
|
|
388
|
+
status: after.status ?? null,
|
|
389
|
+
agentName: spec.agentName,
|
|
390
|
+
at: Date.now(),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Close the pane (`pane close`); an already-closed pane counts as done (idempotent). */
|
|
395
|
+
export function close(paneId, ctx = {}) {
|
|
396
|
+
const probe = available(ctx);
|
|
397
|
+
if (!probe.ok) return { ok: false, state: 'unreachable', reason: probe.reason, paneId };
|
|
398
|
+
const res = run(probe.exec, ['pane', 'close', paneId], { env: ctx.env ?? process.env });
|
|
399
|
+
if (res.ok) return { ok: true, state: 'closed', paneId, at: Date.now() };
|
|
400
|
+
if (res.reason === 'pane_not_found') return { ok: true, state: 'already-closed', paneId, at: Date.now() };
|
|
401
|
+
return { ok: false, state: 'error', reason: res.reason ?? res.stderr ?? 'pane-close-failed', paneId, at: Date.now() };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Focus the assistant's pane (agent surface only; a bare pane has no focus API). */
|
|
405
|
+
export function focus(binding, ctx = {}) {
|
|
406
|
+
const probe = available(ctx);
|
|
407
|
+
if (!probe.ok) return { ok: false, state: 'unreachable', reason: probe.reason };
|
|
408
|
+
if (!binding?.agentName) {
|
|
409
|
+
return { ok: false, state: 'rejected', reason: 'pane-surface-has-no-focus-api', paneId: binding?.paneId ?? null };
|
|
410
|
+
}
|
|
411
|
+
const res = run(probe.exec, ['agent', 'focus', binding.agentName], { env: ctx.env ?? process.env });
|
|
412
|
+
if (!res.ok) return { ok: false, state: 'error', reason: res.reason ?? res.stderr ?? 'agent-focus-failed' };
|
|
413
|
+
return { ok: true, state: 'focused', paneId: binding.paneId ?? null, agentName: binding.agentName };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The launch plan shape the other hosts expose, so `doctor`/`--print-plan` stay uniform. */
|
|
417
|
+
export function plan(ctx = {}) {
|
|
418
|
+
const probe = available(ctx);
|
|
419
|
+
return {
|
|
420
|
+
host,
|
|
421
|
+
version: MIN_VERSION,
|
|
422
|
+
mode: MODE,
|
|
423
|
+
exec: probe.exec,
|
|
424
|
+
available: probe.ok,
|
|
425
|
+
reasons: probe.reasons,
|
|
426
|
+
agentKinds: Object.keys(AGENT_KINDS),
|
|
427
|
+
paneOnlyHosts: PANE_ONLY_HOSTS,
|
|
428
|
+
};
|
|
429
|
+
}
|