@jameslovespancakes/pi-plus 1.0.13 → 1.0.14
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/README.md +8 -25
- package/package.json +5 -6
- package/src/core/config.ts +8 -22
- package/src/domains/agents/format.ts +0 -58
- package/src/domains/agents/index.ts +2 -24
- package/src/domains/remote/config-path.ts +0 -2
- package/src/domains/remote/index.ts +4 -41
- package/src/domains/workflows/runtime/agent-retry.ts +11 -6
- package/src/domains/compact/archive.ts +0 -140
- package/src/domains/compact/chunking.ts +0 -166
- package/src/domains/compact/index.ts +0 -452
- package/src/domains/compact/jev.ts +0 -242
- package/src/domains/compact/policy.ts +0 -255
- package/src/domains/compact/types.ts +0 -79
package/README.md
CHANGED
|
@@ -129,7 +129,9 @@ the one line to run, and verifies.
|
|
|
129
129
|
`remote_test` snapshots your working tree, admission-checks CPU/GPU/disk,
|
|
130
130
|
reserves a slot, and **deletes the uploaded source the moment the run ends**,
|
|
131
131
|
keeping only `test.log` and `result.json`. Cleanup runs on the worker, so it
|
|
132
|
-
still happens if your laptop sleeps.
|
|
132
|
+
still happens if your laptop sleeps. Worker capacity is queried only by
|
|
133
|
+
`remote_status` or `remote_test`; Pi Plus never injects SSH status into ordinary
|
|
134
|
+
chat turns.
|
|
133
135
|
|
|
134
136
|
### Coordinate multiple agents
|
|
135
137
|
|
|
@@ -152,26 +154,9 @@ across every running pi agent. `/board` opens the messaging view:
|
|
|
152
154
|
|
|
153
155
|
`/board setup` installs the server locally (pi starts it each session) or onto
|
|
154
156
|
any Mac or Linux host over SSH, where launchd or systemd brings it back after a
|
|
155
|
-
reboot.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
Better Compact replaces Pi's normal compaction with a reversible local archive,
|
|
160
|
-
deterministic protection and extractive compression. Jev mode adds six-signal
|
|
161
|
-
routing through OpenRouter's decisions API; Jev ranks compression but cannot
|
|
162
|
-
override protected facts or authorize source deletion.
|
|
163
|
-
|
|
164
|
-
```
|
|
165
|
-
/compact better on # switch to local deterministic routing and compact now
|
|
166
|
-
/compact better jev # switch to Jev routing and compact now
|
|
167
|
-
/compact better off # restore Pi compaction and compact now
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
The selected mode persists for later manual and automatic compactions. Jev mode
|
|
171
|
-
requires `OPENROUTER_API_KEY` (or OpenRouter auth configured in Pi). Original
|
|
172
|
-
chunks stay under `~/.pi/agent/super-context/archives/`; the
|
|
173
|
-
`super_context_recall` tool performs bounded retrieval from the current branch's
|
|
174
|
-
checkpoint.
|
|
157
|
+
reboot. Board state is returned only when `agent_board` is called or a real
|
|
158
|
+
board message is delivered; background presence snapshots are not added to
|
|
159
|
+
model context.
|
|
175
160
|
|
|
176
161
|
### Orchestrate repeatable workflows
|
|
177
162
|
|
|
@@ -213,12 +198,11 @@ with workflow options when a task needs them.
|
|
|
213
198
|
| `/remote add \| rename \| remove` | jump to one step |
|
|
214
199
|
| `/board` | live agent board UI |
|
|
215
200
|
| `/board setup \| restart \| clear \| status` | manage the board server |
|
|
216
|
-
| `/compact better on \| off \| jev` | select reversible compaction and compact now |
|
|
217
201
|
| `/workflow` | open the running workflow agent board |
|
|
218
202
|
| `/workflow <name> [args]` | run a bundled workflow |
|
|
219
203
|
|
|
220
204
|
**Tools available to the agent:** `workflow`, `list_models`, `agent_board`,
|
|
221
|
-
`remote_status`, `remote_test
|
|
205
|
+
`remote_status`, `remote_test`.
|
|
222
206
|
|
|
223
207
|
---
|
|
224
208
|
|
|
@@ -230,8 +214,7 @@ Everything lives in one file, `~/.pi/agent/pi-plus.json`, created on first use:
|
|
|
230
214
|
{
|
|
231
215
|
"env": { "ARTIFICIAL_ANALYSIS_API_KEY": "aa_…", "AGENT_BOARD_URL": "ws://…" },
|
|
232
216
|
"policy": { "autoApprove": [], "requireApproval": [], "deny": [] },
|
|
233
|
-
"remote": { "workers": [] }
|
|
234
|
-
"compact": { "better": "off" }
|
|
217
|
+
"remote": { "workers": [] }
|
|
235
218
|
}
|
|
236
219
|
```
|
|
237
220
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jameslovespancakes/pi-plus",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi and more",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
"./src/domains/setup/index.ts",
|
|
38
38
|
"./src/domains/subscriptions/index.ts",
|
|
39
39
|
"./src/domains/models/index.ts",
|
|
40
|
-
"./src/domains/compact/index.ts",
|
|
41
40
|
"./src/domains/workflows/index.ts",
|
|
42
41
|
"./src/domains/agents/index.ts",
|
|
43
42
|
"./src/domains/remote/index.ts"
|
|
@@ -50,10 +49,10 @@
|
|
|
50
49
|
"ws": "^8.18.3"
|
|
51
50
|
},
|
|
52
51
|
"peerDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": "
|
|
54
|
-
"@earendil-works/pi-ai": "
|
|
55
|
-
"@earendil-works/pi-coding-agent": "
|
|
56
|
-
"@earendil-works/pi-tui": "
|
|
52
|
+
"@earendil-works/pi-agent-core": "^0.86.1",
|
|
53
|
+
"@earendil-works/pi-ai": "^0.86.1",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "^0.86.1",
|
|
55
|
+
"@earendil-works/pi-tui": "^0.86.1",
|
|
57
56
|
"typebox": "*"
|
|
58
57
|
},
|
|
59
58
|
"scripts": {
|
package/src/core/config.ts
CHANGED
|
@@ -15,22 +15,14 @@ export interface PolicySection {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export interface RemoteSection {
|
|
18
|
-
injectStatus?: boolean;
|
|
19
18
|
defaults?: Record<string, unknown>;
|
|
20
19
|
workers: Record<string, unknown>[];
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export type BetterCompactMode = "off" | "on" | "jev";
|
|
24
|
-
|
|
25
|
-
export interface CompactSection {
|
|
26
|
-
better: BetterCompactMode;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
22
|
export interface PiPlusConfig {
|
|
30
23
|
env: Record<string, string>;
|
|
31
24
|
policy: PolicySection;
|
|
32
25
|
remote: RemoteSection;
|
|
33
|
-
compact: CompactSection;
|
|
34
26
|
}
|
|
35
27
|
|
|
36
28
|
const DEFAULTS: PiPlusConfig = {
|
|
@@ -40,8 +32,7 @@ const DEFAULTS: PiPlusConfig = {
|
|
|
40
32
|
requireApproval: ["openrouter/*", "google/*", "openai/*"],
|
|
41
33
|
deny: [],
|
|
42
34
|
},
|
|
43
|
-
remote: {
|
|
44
|
-
compact: { better: "off" },
|
|
35
|
+
remote: { workers: [] },
|
|
45
36
|
};
|
|
46
37
|
|
|
47
38
|
/** Legacy file -> section, applied only when that section is still absent. */
|
|
@@ -90,7 +81,7 @@ const MIGRATIONS: { file: string; apply: (raw: any, into: PiPlusConfig) => boole
|
|
|
90
81
|
file: "remote.json",
|
|
91
82
|
apply: (raw, into) => {
|
|
92
83
|
if (into.remote.workers.length > 0 || !Array.isArray(raw?.workers)) return false;
|
|
93
|
-
into.remote = {
|
|
84
|
+
into.remote = { defaults: raw.defaults, workers: raw.workers };
|
|
94
85
|
return true;
|
|
95
86
|
},
|
|
96
87
|
},
|
|
@@ -98,7 +89,7 @@ const MIGRATIONS: { file: string; apply: (raw: any, into: PiPlusConfig) => boole
|
|
|
98
89
|
file: "remote-workers.json",
|
|
99
90
|
apply: (raw, into) => {
|
|
100
91
|
if (into.remote.workers.length > 0 || !Array.isArray(raw?.workers)) return false;
|
|
101
|
-
into.remote = {
|
|
92
|
+
into.remote = { defaults: raw.defaults, workers: raw.workers };
|
|
102
93
|
return true;
|
|
103
94
|
},
|
|
104
95
|
},
|
|
@@ -111,7 +102,6 @@ export function configPath(): string {
|
|
|
111
102
|
}
|
|
112
103
|
|
|
113
104
|
function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
|
|
114
|
-
const better = raw?.compact?.better;
|
|
115
105
|
return {
|
|
116
106
|
env: raw?.env && typeof raw.env === "object" ? { ...raw.env } : {},
|
|
117
107
|
policy: {
|
|
@@ -122,13 +112,9 @@ function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
|
|
|
122
112
|
deny: Array.isArray(raw?.policy?.deny) ? raw.policy.deny : [],
|
|
123
113
|
},
|
|
124
114
|
remote: {
|
|
125
|
-
injectStatus: raw?.remote?.injectStatus !== false,
|
|
126
115
|
defaults: raw?.remote?.defaults,
|
|
127
116
|
workers: Array.isArray(raw?.remote?.workers) ? raw.remote.workers : [],
|
|
128
117
|
},
|
|
129
|
-
compact: {
|
|
130
|
-
better: better === "on" || better === "jev" ? better : "off",
|
|
131
|
-
},
|
|
132
118
|
};
|
|
133
119
|
}
|
|
134
120
|
|
|
@@ -137,16 +123,16 @@ export function readConfig(): PiPlusConfig {
|
|
|
137
123
|
|
|
138
124
|
const path = configPath();
|
|
139
125
|
const exists = existsSync(path);
|
|
140
|
-
const
|
|
126
|
+
const raw = exists ? readJson<Partial<PiPlusConfig>>(path, {}) : undefined;
|
|
127
|
+
const config = normalize(raw);
|
|
141
128
|
|
|
142
|
-
// Only consider legacy sources when the unified file is absent or partial.
|
|
143
129
|
let migrated = false;
|
|
144
130
|
for (const migration of MIGRATIONS) {
|
|
145
131
|
const legacyPath = agentPath(migration.file);
|
|
146
132
|
if (!existsSync(legacyPath)) continue;
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
if (migration.apply(
|
|
133
|
+
const legacy = readJson<any>(legacyPath, undefined);
|
|
134
|
+
if (legacy === undefined) continue;
|
|
135
|
+
if (migration.apply(legacy, config)) migrated = true;
|
|
150
136
|
}
|
|
151
137
|
|
|
152
138
|
if (!exists || migrated) writeJson(path, config, true);
|
|
@@ -1,30 +1,5 @@
|
|
|
1
1
|
const DELIVERY_TEXT_MAX = 2_000;
|
|
2
2
|
|
|
3
|
-
export interface SnapshotAgent {
|
|
4
|
-
sessionId: string;
|
|
5
|
-
alias?: string;
|
|
6
|
-
host: string;
|
|
7
|
-
branch?: string;
|
|
8
|
-
repo?: string;
|
|
9
|
-
commit?: string;
|
|
10
|
-
state: "idle" | "thinking" | "tool";
|
|
11
|
-
lastTool?: string;
|
|
12
|
-
coordinator?: string | null;
|
|
13
|
-
reports?: string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface SnapshotSelf {
|
|
17
|
-
branch?: string;
|
|
18
|
-
repo?: string;
|
|
19
|
-
commit?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface SnapshotCoordination {
|
|
23
|
-
coordinator?: string | null;
|
|
24
|
-
reports?: string[];
|
|
25
|
-
repoThread?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
3
|
export interface DeliveryMessage {
|
|
29
4
|
senderId: string;
|
|
30
5
|
senderAlias?: string;
|
|
@@ -47,39 +22,6 @@ function cleanLine(value: unknown, max: number): string {
|
|
|
47
22
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
48
23
|
}
|
|
49
24
|
|
|
50
|
-
function compactTool(lastTool?: string): string | undefined {
|
|
51
|
-
if (!lastTool) return undefined;
|
|
52
|
-
const separator = lastTool.indexOf(":");
|
|
53
|
-
return cleanLine(separator < 0 ? lastTool : lastTool.slice(0, separator), 32);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function formatBoardSnapshot(
|
|
57
|
-
agents: readonly SnapshotAgent[],
|
|
58
|
-
self: SnapshotSelf,
|
|
59
|
-
coordination: SnapshotCoordination,
|
|
60
|
-
): string {
|
|
61
|
-
const commit = self.commit?.slice(0, 12) || "no-commit";
|
|
62
|
-
const meta = [
|
|
63
|
-
`${self.branch || "-"}@${commit}`,
|
|
64
|
-
`${agents.length} peer${agents.length === 1 ? "" : "s"}`,
|
|
65
|
-
coordination.repoThread ? `room=${coordination.repoThread}` : undefined,
|
|
66
|
-
coordination.coordinator ? `reports-to=${coordination.coordinator}` : undefined,
|
|
67
|
-
coordination.reports?.length ? `reports=${coordination.reports.join(",")}` : undefined,
|
|
68
|
-
].filter(Boolean).join(" ");
|
|
69
|
-
const lines = [`[board ${meta}]`];
|
|
70
|
-
for (const agent of agents.slice(0, 12)) {
|
|
71
|
-
const agentCommit = agent.commit?.slice(0, 12) || "no-commit";
|
|
72
|
-
const sameLine = agent.repo && self.repo === agent.repo && agent.branch === self.branch;
|
|
73
|
-
const drift = sameLine && agent.commit && self.commit && agent.commit !== self.commit ? " !commit" : "";
|
|
74
|
-
const tool = agent.state === "tool" ? compactTool(agent.lastTool) : undefined;
|
|
75
|
-
const activity = tool ? `tool:${tool}` : agent.state;
|
|
76
|
-
const role = agent.reports?.length ? " coord" : agent.coordinator ? ` ->${agent.coordinator}` : "";
|
|
77
|
-
lines.push(`- ${agent.alias || agent.sessionId.slice(0, 8)}@${agent.host} ${activity} ${agent.branch || "-"}@${agentCommit}${drift}${role}`);
|
|
78
|
-
}
|
|
79
|
-
if (agents.length > 12) lines.push(`- +${agents.length - 12} more; use agent_board agents`);
|
|
80
|
-
return lines.join("\n");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
25
|
function compactDeliveryText(text: string): string {
|
|
84
26
|
const clean = text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim();
|
|
85
27
|
if (clean.length <= DELIVERY_TEXT_MAX) return clean;
|
|
@@ -11,11 +11,10 @@ import { Type } from "typebox";
|
|
|
11
11
|
import WebSocket from "ws";
|
|
12
12
|
import { env } from "../../core/env.ts";
|
|
13
13
|
import { handleBoardAdmin, registerBoardLifecycle } from "./board-setup.ts";
|
|
14
|
-
import { formatBoardDeliveries,
|
|
14
|
+
import { formatBoardDeliveries, type BoardDelivery } from "./format.ts";
|
|
15
15
|
|
|
16
16
|
const HEARTBEAT_MS = 8_000;
|
|
17
17
|
const REQUEST_TIMEOUT_MS = 2_000;
|
|
18
|
-
const SNAPSHOT_CACHE_MS = 1_500;
|
|
19
18
|
const MESSAGE_MAX = 8_000;
|
|
20
19
|
const DELIVERY_DEBOUNCE_MS = 250;
|
|
21
20
|
const DELIVERY_BATCH_MAX = 8;
|
|
@@ -304,8 +303,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
304
303
|
let ctx: ExtensionContext | undefined;
|
|
305
304
|
let self: AgentInfo | undefined;
|
|
306
305
|
let gitInfo: GitInfo = {};
|
|
307
|
-
let snapshot: { at: number; agents: AgentInfo[] } = { at: 0, agents: [] };
|
|
308
|
-
let lastInjectedSnapshot: string | undefined;
|
|
309
306
|
let pendingDeliveries: BoardDelivery[] = [];
|
|
310
307
|
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
311
308
|
|
|
@@ -321,12 +318,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
318
|
self = { ...self, ...gitInfo };
|
|
322
319
|
client.presence(gitInfo);
|
|
323
320
|
};
|
|
324
|
-
const snapshotAgents = async (): Promise<AgentInfo[]> => {
|
|
325
|
-
if (Date.now() - snapshot.at > SNAPSHOT_CACHE_MS) {
|
|
326
|
-
snapshot = { at: Date.now(), agents: await client.request("agents", {}, 500) };
|
|
327
|
-
}
|
|
328
|
-
return snapshot.agents;
|
|
329
|
-
};
|
|
330
321
|
const flushDeliveries = () => {
|
|
331
322
|
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
332
323
|
deliveryTimer = undefined;
|
|
@@ -354,7 +345,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
354
345
|
|
|
355
346
|
client.on((event) => {
|
|
356
347
|
if (event.t === "connection") {
|
|
357
|
-
if (event.connected) { snapshot.at = 0; lastInjectedSnapshot = undefined; }
|
|
358
348
|
updateStatus();
|
|
359
349
|
return;
|
|
360
350
|
}
|
|
@@ -374,19 +364,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
374
364
|
});
|
|
375
365
|
pi.on("session_info_changed", async (event) => { if (self) { self.alias = event.name; client.presence({ alias: event.name }); } });
|
|
376
366
|
pi.on("model_select", async (event) => client.presence({ model: `${event.model.provider}/${event.model.id}` }));
|
|
377
|
-
pi.on("before_agent_start",
|
|
367
|
+
pi.on("before_agent_start", (event) => {
|
|
378
368
|
client.presence({ state: "thinking", lastPrompt: cleanLine(event.prompt) });
|
|
379
369
|
client.activity("prompt", event.prompt);
|
|
380
|
-
if (!client.connected) return;
|
|
381
|
-
try {
|
|
382
|
-
const content = formatBoardSnapshot(await snapshotAgents(), gitInfo, client.coordination);
|
|
383
|
-
if (content === lastInjectedSnapshot) return;
|
|
384
|
-
lastInjectedSnapshot = content;
|
|
385
|
-
// Persist one compact snapshot at a user-turn boundary. A transient message
|
|
386
|
-
// appended in `context` becomes Anthropic's final cache breakpoint but is
|
|
387
|
-
// absent from the next transcript, forcing a conversation-cache miss.
|
|
388
|
-
return { message: { customType: "agent-board-snapshot", content, display: false, details: {} } };
|
|
389
|
-
} catch { return; }
|
|
390
370
|
});
|
|
391
371
|
pi.on("tool_execution_start", async (event) => {
|
|
392
372
|
const lastTool = cleanLine(`${event.toolName}: ${JSON.stringify(event.args)}`, 100);
|
|
@@ -394,8 +374,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
374
|
});
|
|
395
375
|
pi.on("tool_execution_end", async () => client.presence({ state: "thinking" }));
|
|
396
376
|
pi.on("agent_settled", async () => { client.presence({ state: "idle", lastTool: undefined }); refreshGit(); });
|
|
397
|
-
pi.on("session_compact", async () => { lastInjectedSnapshot = undefined; });
|
|
398
|
-
pi.on("session_tree", async () => { lastInjectedSnapshot = undefined; });
|
|
399
377
|
pi.on("session_shutdown", async (_event, eventCtx) => {
|
|
400
378
|
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
401
379
|
deliveryTimer = undefined; pendingDeliveries = [];
|
|
@@ -20,7 +20,6 @@ export interface RemoteWorkerRecord {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface RemoteSettings {
|
|
23
|
-
injectStatus: boolean;
|
|
24
23
|
defaults: Record<string, unknown>;
|
|
25
24
|
workers: RemoteWorkerRecord[];
|
|
26
25
|
}
|
|
@@ -28,7 +27,6 @@ export interface RemoteSettings {
|
|
|
28
27
|
export function readRemote(): RemoteSettings {
|
|
29
28
|
const section = readConfig().remote;
|
|
30
29
|
return {
|
|
31
|
-
injectStatus: section.injectStatus !== false,
|
|
32
30
|
defaults: (section.defaults as Record<string, unknown>) ?? {},
|
|
33
31
|
workers: (section.workers as RemoteWorkerRecord[]) ?? [],
|
|
34
32
|
};
|
|
@@ -31,7 +31,6 @@ interface Limits {
|
|
|
31
31
|
gpuMemoryBlockPercent: number;
|
|
32
32
|
memoryBlockPercent: number;
|
|
33
33
|
minimumFreeDiskGB: number;
|
|
34
|
-
statusCacheSeconds: number;
|
|
35
34
|
retentionHours: number;
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -58,7 +57,6 @@ interface Worker extends Omit<WorkerInput, keyof Limits | "enabled">, Limits {
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
interface Config {
|
|
61
|
-
injectStatus: boolean;
|
|
62
60
|
workers: Worker[];
|
|
63
61
|
}
|
|
64
62
|
|
|
@@ -100,7 +98,6 @@ const DEFAULT_LIMITS: Limits = {
|
|
|
100
98
|
gpuMemoryBlockPercent: 90,
|
|
101
99
|
memoryBlockPercent: 90,
|
|
102
100
|
minimumFreeDiskGB: 10,
|
|
103
|
-
statusCacheSeconds: 30,
|
|
104
101
|
retentionHours: 24,
|
|
105
102
|
};
|
|
106
103
|
|
|
@@ -117,8 +114,6 @@ const HARD_WALK_EXCLUDES = new Set([
|
|
|
117
114
|
"remote_tests",
|
|
118
115
|
]);
|
|
119
116
|
|
|
120
|
-
let statusCache: { expiresAt: number; key: string; statuses: WorkerStatus[] } | undefined;
|
|
121
|
-
|
|
122
117
|
function finiteNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
123
118
|
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
|
|
124
119
|
}
|
|
@@ -139,7 +134,6 @@ async function loadConfig(): Promise<Config> {
|
|
|
139
134
|
),
|
|
140
135
|
memoryBlockPercent: finiteNumber(defaults.memoryBlockPercent, DEFAULT_LIMITS.memoryBlockPercent, 1, 100),
|
|
141
136
|
minimumFreeDiskGB: finiteNumber(defaults.minimumFreeDiskGB, DEFAULT_LIMITS.minimumFreeDiskGB, 0, 100000),
|
|
142
|
-
statusCacheSeconds: finiteNumber(defaults.statusCacheSeconds, DEFAULT_LIMITS.statusCacheSeconds, 1, 600),
|
|
143
137
|
retentionHours: finiteNumber(defaults.retentionHours, DEFAULT_LIMITS.retentionHours, 1, 24 * 365),
|
|
144
138
|
};
|
|
145
139
|
const names = new Set<string>();
|
|
@@ -172,11 +166,10 @@ async function loadConfig(): Promise<Config> {
|
|
|
172
166
|
),
|
|
173
167
|
memoryBlockPercent: finiteNumber(raw.memoryBlockPercent, defaultLimits.memoryBlockPercent, 1, 100),
|
|
174
168
|
minimumFreeDiskGB: finiteNumber(raw.minimumFreeDiskGB, defaultLimits.minimumFreeDiskGB, 0, 100000),
|
|
175
|
-
statusCacheSeconds: finiteNumber(raw.statusCacheSeconds, defaultLimits.statusCacheSeconds, 1, 600),
|
|
176
169
|
retentionHours: finiteNumber(raw.retentionHours, defaultLimits.retentionHours, 1, 24 * 365),
|
|
177
170
|
};
|
|
178
171
|
});
|
|
179
|
-
return {
|
|
172
|
+
return { workers };
|
|
180
173
|
}
|
|
181
174
|
|
|
182
175
|
/** `-i`/`-p` only when the worker was added without an ~/.ssh/config entry. */
|
|
@@ -331,27 +324,15 @@ async function probeWorker(worker: Worker): Promise<WorkerStatus> {
|
|
|
331
324
|
}
|
|
332
325
|
}
|
|
333
326
|
|
|
334
|
-
async function probeWorkers(config: Config
|
|
327
|
+
async function probeWorkers(config: Config): Promise<WorkerStatus[]> {
|
|
335
328
|
const active = config.workers.filter((worker) => worker.enabled);
|
|
336
|
-
|
|
337
|
-
const key = JSON.stringify(active.map((worker) => [worker.name, worker.ssh, worker.root]));
|
|
338
|
-
const ttlSeconds = Math.min(...active.map((worker) => worker.statusCacheSeconds));
|
|
339
|
-
if (!force && statusCache && statusCache.key === key && statusCache.expiresAt > Date.now()) return statusCache.statuses;
|
|
340
|
-
const statuses = await Promise.all(active.map(probeWorker));
|
|
341
|
-
statusCache = { key, statuses, expiresAt: Date.now() + ttlSeconds * 1000 };
|
|
342
|
-
return statuses;
|
|
329
|
+
return Promise.all(active.map(probeWorker));
|
|
343
330
|
}
|
|
344
331
|
|
|
345
332
|
function metric(value: number | undefined): string {
|
|
346
333
|
return value === undefined ? "?" : `${Math.round(value)}%`;
|
|
347
334
|
}
|
|
348
335
|
|
|
349
|
-
function compactStatus(status: WorkerStatus): string {
|
|
350
|
-
const gpu = status.gpuPercent === undefined ? "GPU ?" : `GPU ${metric(status.gpuPercent)}`;
|
|
351
|
-
const reason = status.reasons.length ? ` (${status.reasons.join(", ")})` : "";
|
|
352
|
-
return `${status.name} ${status.state.toUpperCase()} CPU ${metric(status.cpuPercent)} MEM ${metric(status.memoryPercent)} ${gpu} active-jobs ${status.jobs ?? "?"}${reason}`;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
336
|
function detailedStatuses(statuses: WorkerStatus[]): string {
|
|
356
337
|
return statuses
|
|
357
338
|
.map((status) => {
|
|
@@ -696,7 +677,6 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
696
677
|
const selected = params.host ? enabled.filter((worker) => worker.name === params.host) : enabled;
|
|
697
678
|
if (params.host && selected.length === 0) throw new Error(`Unknown remote worker: ${params.host}`);
|
|
698
679
|
const statuses = await Promise.all(selected.map(probeWorker));
|
|
699
|
-
statusCache = undefined;
|
|
700
680
|
return {
|
|
701
681
|
content: [{ type: "text", text: detailedStatuses(statuses) }],
|
|
702
682
|
details: { statuses },
|
|
@@ -745,7 +725,7 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
745
725
|
if (requestedHost !== "auto" && !enabledWorkers.some((worker) => worker.name === requestedHost)) {
|
|
746
726
|
throw new Error(`Unknown remote worker: ${requestedHost}. Available: ${enabledWorkers.map((w) => w.name).join(", ")}`);
|
|
747
727
|
}
|
|
748
|
-
|
|
728
|
+
const statuses = await probeWorkers(config);
|
|
749
729
|
let worker = pickWorker(config, statuses, requestedHost, params.requiresGpu ?? false);
|
|
750
730
|
if (!worker) return blockedResult(undefined, undefined, detailedStatuses(statuses));
|
|
751
731
|
let workerStatus = statuses.find((status) => status.name === worker!.name);
|
|
@@ -818,7 +798,6 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
818
798
|
text += `\n\n[Output truncated; full log: ${remoteJob}/test.log]`;
|
|
819
799
|
}
|
|
820
800
|
void runSsh(worker, "bash -s", { input: cleanupScript(worker, snapshot.repoName), timeoutSeconds: 15 }).catch(() => {});
|
|
821
|
-
statusCache = undefined;
|
|
822
801
|
return {
|
|
823
802
|
content: [{ type: "text", text }],
|
|
824
803
|
details: {
|
|
@@ -847,20 +826,4 @@ export default function remoteJobsExtension(pi: ExtensionAPI) {
|
|
|
847
826
|
},
|
|
848
827
|
});
|
|
849
828
|
|
|
850
|
-
pi.on("before_agent_start", async (event) => {
|
|
851
|
-
try {
|
|
852
|
-
const config = await loadConfig();
|
|
853
|
-
if (!config.injectStatus) return;
|
|
854
|
-
const statuses = await probeWorkers(config);
|
|
855
|
-
if (statuses.length === 0) return;
|
|
856
|
-
const line = statuses.map(compactStatus).join("; ");
|
|
857
|
-
return {
|
|
858
|
-
systemPrompt:
|
|
859
|
-
event.systemPrompt +
|
|
860
|
-
`\n\nRemote worker capacity (recent sample): ${line}. remote_test always performs a fresh hard admission check. Never retry a BLOCKED worker immediately; choose another READY worker or run locally.`,
|
|
861
|
-
};
|
|
862
|
-
} catch {
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
829
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
export const AGENT_RETRY_BASE_DELAY_MS = 1_000;
|
|
9
9
|
export const AGENT_RETRY_MAX_DELAY_MS = 30_000;
|
|
10
10
|
export const WORKFLOW_PROVIDER_ERROR_CODE = "WORKFLOW_PROVIDER_ERROR";
|
|
11
|
+
const CODEX_ACCESS_VERIFICATION_ERROR = /Unable to verify\s+[^.\r\n]{1,120}\s+access\.\s*Please try again\.?/i;
|
|
11
12
|
|
|
12
13
|
export interface AgentRetryScheduler {
|
|
13
14
|
sleep(delayMs: number, signal: AbortSignal | undefined): Promise<void>;
|
|
@@ -25,12 +26,11 @@ export interface ProviderErrorDetails {
|
|
|
25
26
|
export class WorkflowProviderError extends Error {
|
|
26
27
|
override readonly name = "WorkflowProviderError";
|
|
27
28
|
readonly code = WORKFLOW_PROVIDER_ERROR_CODE;
|
|
29
|
+
readonly details: ProviderErrorDetails;
|
|
28
30
|
|
|
29
|
-
constructor(
|
|
30
|
-
message: string,
|
|
31
|
-
readonly details: ProviderErrorDetails,
|
|
32
|
-
) {
|
|
31
|
+
constructor(message: string, details: ProviderErrorDetails) {
|
|
33
32
|
super(message);
|
|
33
|
+
this.details = details;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
get retryable(): boolean {
|
|
@@ -79,13 +79,18 @@ export function providerErrorFromMessages(
|
|
|
79
79
|
if (usageLimit && options.pauseOnUsageLimit) return usageLimit;
|
|
80
80
|
const message = messages.findLast(isAssistantMessage);
|
|
81
81
|
if (!message || message.stopReason !== "error") return undefined;
|
|
82
|
-
const
|
|
82
|
+
const providerMessage = typeof message.errorMessage === "string" && message.errorMessage.length > 0
|
|
83
83
|
? message.errorMessage
|
|
84
84
|
: "Provider session ended with an unspecified error.";
|
|
85
|
-
const retryable = isRetryableAssistantError(message as AssistantMessage);
|
|
86
85
|
const provider = stringDetail(message.provider);
|
|
87
86
|
const model = stringDetail(message.model);
|
|
88
87
|
const api = stringDetail(message.api);
|
|
88
|
+
const codexAccessVerification = provider === "openai-codex"
|
|
89
|
+
&& CODEX_ACCESS_VERIFICATION_ERROR.test(providerMessage);
|
|
90
|
+
const retryable = isRetryableAssistantError(message as AssistantMessage) || codexAccessVerification;
|
|
91
|
+
const errorMessage = codexAccessVerification && model
|
|
92
|
+
? `Codex temporarily could not verify access for the selected model ${provider}/${model}. No alternate model was requested. Provider response: ${providerMessage}`
|
|
93
|
+
: providerMessage;
|
|
89
94
|
return new WorkflowProviderError(errorMessage, {
|
|
90
95
|
stopReason: "error",
|
|
91
96
|
retryable,
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { basename } from "node:path";
|
|
4
|
-
import { agentPath, writeJson } from "../../core/store.ts";
|
|
5
|
-
import type { ArchiveRecord, SemanticChunk, SuperContextArchive } from "./types.ts";
|
|
6
|
-
|
|
7
|
-
function safeSessionId(sessionId: string): string {
|
|
8
|
-
const readable = sessionId.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80) || "session";
|
|
9
|
-
const suffix = createHash("sha256").update(sessionId).digest("hex").slice(0, 10);
|
|
10
|
-
return `${readable}-${suffix}`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function archivePath(sessionId: string): string {
|
|
14
|
-
return agentPath("super-context", "archives", `${safeSessionId(sessionId)}.json`);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function emptyArchive(sessionId: string): SuperContextArchive {
|
|
18
|
-
return { version: 1, sessionId, records: [], checkpoints: {} };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function isArchiveRecord(value: unknown): value is ArchiveRecord {
|
|
22
|
-
if (!value || typeof value !== "object") return false;
|
|
23
|
-
const record = value as Partial<ArchiveRecord>;
|
|
24
|
-
return typeof record.id === "string"
|
|
25
|
-
&& typeof record.hash === "string"
|
|
26
|
-
&& typeof record.text === "string"
|
|
27
|
-
&& typeof record.role === "string"
|
|
28
|
-
&& typeof record.ordinal === "number"
|
|
29
|
-
&& typeof record.archivedAt === "string";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Refuse to overwrite a corrupt archive: source recoverability wins over convenience. */
|
|
33
|
-
export function loadArchive(sessionId: string): SuperContextArchive {
|
|
34
|
-
const path = archivePath(sessionId);
|
|
35
|
-
if (!existsSync(path)) return emptyArchive(sessionId);
|
|
36
|
-
|
|
37
|
-
let parsed: unknown;
|
|
38
|
-
try {
|
|
39
|
-
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
40
|
-
} catch (error) {
|
|
41
|
-
throw new Error(`Super Context archive is unreadable; refusing to overwrite ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
42
|
-
}
|
|
43
|
-
if (!parsed || typeof parsed !== "object") throw new Error(`Invalid Super Context archive: ${path}`);
|
|
44
|
-
const candidate = parsed as Partial<SuperContextArchive>;
|
|
45
|
-
if (candidate.version !== 1 || candidate.sessionId !== sessionId || !Array.isArray(candidate.records)) {
|
|
46
|
-
throw new Error(`Unsupported Super Context archive format: ${path}`);
|
|
47
|
-
}
|
|
48
|
-
if (!candidate.records.every(isArchiveRecord)) throw new Error(`Invalid Super Context archive records: ${path}`);
|
|
49
|
-
const checkpoints = candidate.checkpoints;
|
|
50
|
-
if (!checkpoints || typeof checkpoints !== "object" || Array.isArray(checkpoints)) {
|
|
51
|
-
throw new Error(`Invalid Super Context archive checkpoints: ${path}`);
|
|
52
|
-
}
|
|
53
|
-
for (const ids of Object.values(checkpoints)) {
|
|
54
|
-
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) {
|
|
55
|
-
throw new Error(`Invalid Super Context archive checkpoint: ${path}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return candidate as SuperContextArchive;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function addChunks(archive: SuperContextArchive, chunks: readonly SemanticChunk[]): void {
|
|
62
|
-
const byId = new Map(archive.records.map((record) => [record.id, record]));
|
|
63
|
-
let ordinal = archive.records.reduce((maximum, record) => Math.max(maximum, record.ordinal), -1) + 1;
|
|
64
|
-
const archivedAt = new Date().toISOString();
|
|
65
|
-
for (const chunk of chunks) {
|
|
66
|
-
const existing = byId.get(chunk.id);
|
|
67
|
-
if (existing) {
|
|
68
|
-
if (existing.hash !== chunk.hash || existing.text !== chunk.text) {
|
|
69
|
-
throw new Error(`Super Context archive ID collision for ${chunk.id}`);
|
|
70
|
-
}
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const record: ArchiveRecord = { ...chunk, ordinal, archivedAt };
|
|
74
|
-
ordinal += 1;
|
|
75
|
-
archive.records.push(record);
|
|
76
|
-
byId.set(record.id, record);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function checkpointHash(recordIds: readonly string[]): string {
|
|
81
|
-
const digest = createHash("sha256").update(recordIds.join("\0")).digest("hex");
|
|
82
|
-
return `SCC-${digest.slice(0, 20)}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export interface PersistedCheckpoint {
|
|
86
|
-
archive: SuperContextArchive;
|
|
87
|
-
checkpointId: string;
|
|
88
|
-
records: ArchiveRecord[];
|
|
89
|
-
duplicateChunks: number;
|
|
90
|
-
file: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Add-only persistence for immutable records and content-addressed checkpoints. */
|
|
94
|
-
export function persistCheckpoint(
|
|
95
|
-
sessionId: string,
|
|
96
|
-
chunks: readonly SemanticChunk[],
|
|
97
|
-
priorRecordIds: readonly string[],
|
|
98
|
-
): PersistedCheckpoint {
|
|
99
|
-
const archive = loadArchive(sessionId);
|
|
100
|
-
const currentIds = chunks.map((chunk) => chunk.id);
|
|
101
|
-
const allRecordIds = [...priorRecordIds, ...currentIds];
|
|
102
|
-
const duplicateChunks = allRecordIds.length - new Set(allRecordIds).size;
|
|
103
|
-
addChunks(archive, chunks);
|
|
104
|
-
|
|
105
|
-
const knownIds = new Set(archive.records.map((record) => record.id));
|
|
106
|
-
const recordIds = [...new Set(allRecordIds)];
|
|
107
|
-
const missing = recordIds.filter((id) => !knownIds.has(id));
|
|
108
|
-
if (missing.length > 0) throw new Error(`Super Context archive is missing ${missing.length} source record(s)`);
|
|
109
|
-
|
|
110
|
-
const checkpointId = checkpointHash(recordIds);
|
|
111
|
-
const existing = archive.checkpoints[checkpointId];
|
|
112
|
-
if (existing && JSON.stringify(existing) !== JSON.stringify(recordIds)) {
|
|
113
|
-
throw new Error(`Super Context checkpoint collision for ${checkpointId}`);
|
|
114
|
-
}
|
|
115
|
-
archive.checkpoints[checkpointId] ??= recordIds;
|
|
116
|
-
|
|
117
|
-
const file = archivePath(sessionId);
|
|
118
|
-
if (!writeJson(file, archive, false, 0o600)) throw new Error(`Failed to persist Super Context archive: ${file}`);
|
|
119
|
-
const byId = new Map(archive.records.map((record) => [record.id, record]));
|
|
120
|
-
return {
|
|
121
|
-
archive,
|
|
122
|
-
checkpointId,
|
|
123
|
-
records: recordIds.map((id) => byId.get(id)!),
|
|
124
|
-
duplicateChunks,
|
|
125
|
-
file,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function checkpointRecords(archive: SuperContextArchive, checkpointId: string | undefined): ArchiveRecord[] {
|
|
130
|
-
if (!checkpointId) return [];
|
|
131
|
-
const ids = archive.checkpoints[checkpointId];
|
|
132
|
-
if (!ids) return [];
|
|
133
|
-
const byId = new Map(archive.records.map((record) => [record.id, record]));
|
|
134
|
-
const records = ids.map((id) => byId.get(id)).filter((record): record is ArchiveRecord => record !== undefined);
|
|
135
|
-
return records.length === ids.length ? records : [];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function archiveDisplayName(file: string): string {
|
|
139
|
-
return basename(file);
|
|
140
|
-
}
|