@selesai/code 0.13.38 → 0.13.40
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/CHANGELOG.md +7 -0
- package/dist/core/intercom-rpc.d.ts +135 -0
- package/dist/core/intercom-rpc.js +64 -0
- package/dist/core/release-rpc.d.ts +137 -0
- package/dist/core/release-rpc.js +77 -0
- package/dist/core/resource-loader.d.ts +3 -0
- package/dist/core/resource-loader.js +3 -0
- package/dist/core/subagent-rpc.d.ts +110 -0
- package/dist/core/subagent-rpc.js +60 -0
- package/dist/extensions/agent-browser.ts +9 -0
- package/dist/extensions/pi-graft/cli.test.ts +5 -5
- package/dist/extensions/pi-graft/cli.ts +10 -5
- package/dist/extensions/pi-graft/commands.test.ts +5 -5
- package/dist/extensions/pi-graft/graft.integration.test.ts +1 -1
- package/dist/extensions/pi-graft/lifecycle.test.ts +26 -26
- package/dist/extensions/pi-graft/prompt.test.ts +1 -1
- package/dist/extensions/pi-graft/state.test.ts +9 -9
- package/dist/extensions/pi-graft/test-support.ts +1 -1
- package/dist/extensions/pi-graft/tools.test.ts +1 -1
- package/dist/extensions/pi-intercom/README.md +15 -0
- package/dist/extensions/pi-intercom/broker/broker.ts +277 -13
- package/dist/extensions/pi-intercom/broker/client.ts +6 -2
- package/dist/extensions/pi-intercom/broker/mailbox-persistence.test.ts +523 -0
- package/dist/extensions/pi-intercom/broker/paths.test.ts +4 -4
- package/dist/extensions/pi-intercom/broker/paths.ts +4 -2
- package/dist/extensions/pi-intercom/broker/spawn.test.ts +1 -3
- package/dist/extensions/pi-intercom/extension-api.ts +10 -0
- package/dist/extensions/pi-intercom/index.ts +188 -8
- package/dist/extensions/pi-intercom/intercom.integration.test.ts +280 -0
- package/dist/extensions/pi-intercom/package.json +1 -1
- package/dist/extensions/pi-intercom/release-readiness.test.ts +116 -0
- package/dist/extensions/pi-intercom/release-readiness.ts +93 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +8 -0
- package/dist/extensions/pi-subagents/docs/observability.md +8 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +51 -1
- package/dist/extensions/pi-subagents/src/extension/observability.ts +164 -0
- package/dist/extensions/pi-subagents/src/extension/release-readiness.ts +367 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +8 -1
- package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +7 -1
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +1 -44
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +13 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-delivery-ownership.ts +269 -7
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +11 -10
- package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +15 -3
- package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +64 -0
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +69 -6
- package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +14 -1
- package/dist/extensions/pi-subagents/src/shared/owner-record.ts +107 -0
- package/dist/extensions/pi-subagents/src/shared/process-identity.ts +87 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +9 -0
- package/dist/extensions/pi-subagents/test/integration/async-attention-clearing.test.ts +191 -0
- package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +63 -0
- package/dist/extensions/pi-subagents/test/integration/stop-all-background.test.ts +142 -0
- package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +8 -0
- package/dist/extensions/pi-subagents/test/unit/observability.test.ts +198 -0
- package/dist/extensions/pi-subagents/test/unit/process-identity.test.ts +75 -0
- package/dist/extensions/pi-subagents/test/unit/release-readiness.test.ts +715 -0
- package/dist/extensions/pi-subagents/test/unit/result-takeover.test.ts +332 -0
- package/dist/extensions/tokenin-onboarding.ts +142 -3
- package/dist/index.d.ts +6 -1
- package/dist/index.js +7 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1 -1
- package/dist/modes/index.d.ts +1 -1
- package/dist/modes/rpc/intercom-bridge.d.ts +14 -0
- package/dist/modes/rpc/intercom-bridge.js +75 -0
- package/dist/modes/rpc/intercom-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/intercom-bridge.test.js +65 -0
- package/dist/modes/rpc/release-bridge.d.ts +35 -0
- package/dist/modes/rpc/release-bridge.js +159 -0
- package/dist/modes/rpc/release-bridge.test.d.ts +1 -0
- package/dist/modes/rpc/release-bridge.test.js +347 -0
- package/dist/modes/rpc/rpc-client.d.ts +34 -10
- package/dist/modes/rpc/rpc-client.js +45 -2
- package/dist/modes/rpc/rpc-intercom-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-intercom-transport.test.js +85 -0
- package/dist/modes/rpc/rpc-mode.d.ts +1 -13
- package/dist/modes/rpc/rpc-mode.js +239 -8
- package/dist/modes/rpc/rpc-release-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-release-transport.test.js +329 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.d.ts +1 -0
- package/dist/modes/rpc/rpc-subagent-transport.test.js +60 -0
- package/dist/modes/rpc/rpc-types.d.ts +12 -2
- package/dist/modes/rpc/rpc-types.js +0 -6
- package/dist/modes/rpc/subagent-bridge.d.ts +23 -0
- package/dist/modes/rpc/subagent-bridge.js +93 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.d.ts +1 -0
- package/dist/modes/rpc/test-fixtures/intercom-host.js +13 -0
- package/docs/rpc-subagent.schema.json +491 -0
- package/docs/rpc.md +210 -1
- package/package.json +2 -1
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { afterEach, describe, it } from "node:test";
|
|
7
|
+
import { createResultDeliveryOwnership, type ResultTakeoverDeps } from "../../src/runs/background/result-delivery-ownership.ts";
|
|
8
|
+
import { ensureOwnerRecord, ownerFileStem, ownerRecordPath, readOwnerRecord, type OwnerRecord } from "../../src/shared/owner-record.ts";
|
|
9
|
+
import { processLiveness, processStartKey } from "../../src/shared/process-identity.ts";
|
|
10
|
+
|
|
11
|
+
const roots: string[] = [];
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
function tempDir(): string {
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-takeover-test-"));
|
|
17
|
+
roots.push(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HOST = "host-a";
|
|
22
|
+
const SESSION = "session-1";
|
|
23
|
+
|
|
24
|
+
function writeRecord(ownersDir: string, id: string, overrides: Partial<OwnerRecord> = {}): OwnerRecord {
|
|
25
|
+
const record: OwnerRecord = { version: 1, completionOwnerId: id, pid: 4242, hostname: HOST, createdAt: 1_000, ...overrides };
|
|
26
|
+
fs.mkdirSync(ownersDir, { recursive: true });
|
|
27
|
+
fs.writeFileSync(ownerRecordPath(ownersDir, id)!, JSON.stringify(record));
|
|
28
|
+
return record;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Liveness = "alive" | "dead" | "unknown";
|
|
32
|
+
|
|
33
|
+
/** pid -> liveness table so each fake owner can live or die independently. */
|
|
34
|
+
function fakes(ownersDir: string, table: Record<number, Liveness>, overrides: Partial<ResultTakeoverDeps> = {}): ResultTakeoverDeps {
|
|
35
|
+
return {
|
|
36
|
+
ownersDir,
|
|
37
|
+
hostname: () => HOST,
|
|
38
|
+
processLiveness: (pid) => table[pid] ?? "unknown",
|
|
39
|
+
processStartKey: () => undefined,
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ownershipFor(ownerId: string, ownersDir: string, table: Record<number, Liveness>, sessionId: string | null = SESSION, overrides: Partial<ResultTakeoverDeps> = {}) {
|
|
45
|
+
const state = { currentSessionId: sessionId, completionOwnerId: ownerId };
|
|
46
|
+
return { state, ownership: createResultDeliveryOwnership(state, fakes(ownersDir, table, overrides)) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function claimFiles(ownersDir: string): string[] {
|
|
50
|
+
try {
|
|
51
|
+
return fs.readdirSync(path.join(ownersDir, "claims")).sort();
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("cross-process result takeover", () => {
|
|
58
|
+
it("takes over a dead same-host owner's result for the current session, exactly once", () => {
|
|
59
|
+
const dir = tempDir();
|
|
60
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
61
|
+
const { ownership } = ownershipFor("me", dir, { 100: "dead" }, SESSION, { now: () => 5_000 });
|
|
62
|
+
assert.equal(ownership.owns(SESSION, "dead-owner", "run-1"), true);
|
|
63
|
+
assert.equal(ownership.owns(SESSION, "dead-owner", "run-1"), true, "idempotent for the same claimant");
|
|
64
|
+
assert.deepEqual(claimFiles(dir), ["run-1.json"]);
|
|
65
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, "claims", "run-1.json"), "utf-8")), { claimedBy: "me", previousOwner: "dead-owner", claimedAt: 5_000 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("never takes over from a live owner", () => {
|
|
69
|
+
const dir = tempDir();
|
|
70
|
+
writeRecord(dir, "live-owner", { pid: 100 });
|
|
71
|
+
const { ownership } = ownershipFor("me", dir, { 100: "alive" });
|
|
72
|
+
assert.equal(ownership.owns(SESSION, "live-owner", "run-1"), false);
|
|
73
|
+
assert.equal(ownership.canTakeOver(SESSION, "live-owner", "run-1"), false);
|
|
74
|
+
assert.deepEqual(claimFiles(dir), []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not take over without an owner record", () => {
|
|
78
|
+
const dir = tempDir();
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const { ownership } = ownershipFor("me", dir, { 100: "dead" });
|
|
81
|
+
assert.equal(ownership.owns(SESSION, "no-record", "run-1"), false);
|
|
82
|
+
fs.writeFileSync(ownerRecordPath(dir, "garbage")!, "{not json");
|
|
83
|
+
assert.equal(ownership.owns(SESSION, "garbage", "run-1"), false);
|
|
84
|
+
writeRecord(dir, "wrong-id", { pid: 100 });
|
|
85
|
+
fs.renameSync(ownerRecordPath(dir, "wrong-id")!, ownerRecordPath(dir, "impostor")!);
|
|
86
|
+
assert.equal(ownership.owns(SESSION, "impostor", "run-1"), false, "a record must describe the id it is filed under");
|
|
87
|
+
assert.deepEqual(claimFiles(dir), []);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not take over when liveness is unknown", () => {
|
|
91
|
+
const dir = tempDir();
|
|
92
|
+
writeRecord(dir, "mystery", { pid: 100 });
|
|
93
|
+
const { ownership } = ownershipFor("me", dir, { 100: "unknown" });
|
|
94
|
+
assert.equal(ownership.owns(SESSION, "mystery", "run-1"), false);
|
|
95
|
+
assert.deepEqual(claimFiles(dir), []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not take over from another host", () => {
|
|
99
|
+
const dir = tempDir();
|
|
100
|
+
writeRecord(dir, "elsewhere", { pid: 100, hostname: "host-b" });
|
|
101
|
+
const { ownership } = ownershipFor("me", dir, { 100: "dead" });
|
|
102
|
+
assert.equal(ownership.owns(SESSION, "elsewhere", "run-1"), false);
|
|
103
|
+
assert.deepEqual(claimFiles(dir), []);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("treats a reused pid (different start key) as a dead owner, but only with comparable evidence", () => {
|
|
107
|
+
const dir = tempDir();
|
|
108
|
+
const cases: Array<{ name: string; recorded?: string; current?: string; takeable: boolean }> = [
|
|
109
|
+
{ name: "reused", recorded: "ps:Mon Oct 4 10:00:00 2026", current: "ps:Mon Oct 4 12:30:00 2026", takeable: true },
|
|
110
|
+
{ name: "same-process", recorded: "ps:Mon Oct 4 10:00:00 2026", current: "ps:Mon Oct 4 10:00:00 2026", takeable: false },
|
|
111
|
+
{ name: "no-recorded-key", current: "ps:Mon Oct 4 12:30:00 2026", takeable: false },
|
|
112
|
+
{ name: "no-current-key", recorded: "ps:Mon Oct 4 10:00:00 2026", takeable: false },
|
|
113
|
+
{ name: "scheme-changed", recorded: "linux:12345", current: "ps:Mon Oct 4 12:30:00 2026", takeable: false },
|
|
114
|
+
];
|
|
115
|
+
for (const entry of cases) {
|
|
116
|
+
writeRecord(dir, entry.name, { pid: 100, ...(entry.recorded ? { startKey: entry.recorded } : {}) });
|
|
117
|
+
const { ownership } = ownershipFor("me", dir, { 100: "alive" }, SESSION, { processStartKey: () => entry.current });
|
|
118
|
+
assert.equal(ownership.owns(SESSION, entry.name, `run-${entry.name}`), entry.takeable, entry.name);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("never takes another session's result, even from a dead owner", () => {
|
|
123
|
+
const dir = tempDir();
|
|
124
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
125
|
+
const { state, ownership } = ownershipFor("me", dir, { 100: "dead" });
|
|
126
|
+
assert.equal(ownership.owns("other-session", "dead-owner", "run-1"), false);
|
|
127
|
+
assert.equal(ownership.canTakeOver("other-session", "dead-owner", "run-1"), false);
|
|
128
|
+
assert.deepEqual(claimFiles(dir), []);
|
|
129
|
+
// An already-claimed predecessor session is part of this process's scope, exactly as for in-process ownership.
|
|
130
|
+
state.currentSessionId = "old";
|
|
131
|
+
assert.equal(ownership.claimPredecessor("old", "old"), true);
|
|
132
|
+
state.currentSessionId = "new";
|
|
133
|
+
assert.equal(ownership.owns("old", "dead-owner", "run-old"), true);
|
|
134
|
+
assert.equal(ownership.owns("new", "dead-owner", "run-new"), true);
|
|
135
|
+
assert.equal(ownership.owns("other-session", "dead-owner", "run-2"), false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("requires a run key and a non-empty foreign owner id; the in-process rule is unchanged", () => {
|
|
139
|
+
const dir = tempDir();
|
|
140
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
141
|
+
const { ownership } = ownershipFor("me", dir, { 100: "dead" });
|
|
142
|
+
assert.equal(ownership.owns(SESSION, "dead-owner"), false);
|
|
143
|
+
assert.equal(ownership.owns(SESSION, undefined, "run-1"), false);
|
|
144
|
+
assert.equal(ownership.owns(SESSION, "", "run-1"), false);
|
|
145
|
+
assert.equal(ownership.owns(SESSION, "me", "run-1"), true);
|
|
146
|
+
assert.equal(ownership.owns("other-session", "me", "run-1"), false);
|
|
147
|
+
assert.deepEqual(claimFiles(dir), []);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("lets exactly one of two racing processes win a result", () => {
|
|
151
|
+
for (const order of [["a", "b"], ["b", "a"]] as const) {
|
|
152
|
+
const dir = tempDir();
|
|
153
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
154
|
+
const table: Record<number, Liveness> = { 100: "dead", 201: "alive", 202: "alive" };
|
|
155
|
+
writeRecord(dir, "proc-a", { pid: 201 });
|
|
156
|
+
writeRecord(dir, "proc-b", { pid: 202 });
|
|
157
|
+
const a = ownershipFor("proc-a", dir, table);
|
|
158
|
+
const b = ownershipFor("proc-b", dir, table);
|
|
159
|
+
const by = { a: a.ownership, b: b.ownership };
|
|
160
|
+
const results = order.map((name) => by[name].owns(SESSION, "dead-owner", "run-1"));
|
|
161
|
+
assert.deepEqual(results, [true, false], `first caller (${order[0]}) wins`);
|
|
162
|
+
assert.equal(by[order[0]].owns(SESSION, "dead-owner", "run-1"), true);
|
|
163
|
+
assert.equal(by[order[1]].owns(SESSION, "dead-owner", "run-1"), false);
|
|
164
|
+
assert.equal(by[order[1]].canTakeOver(SESSION, "dead-owner", "run-1"), false);
|
|
165
|
+
assert.deepEqual(claimFiles(dir), ["run-1.json"]);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("re-claims a result whose claimer died, race-safely and with a bounded chain", () => {
|
|
170
|
+
const dir = tempDir();
|
|
171
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
172
|
+
writeRecord(dir, "claimer-1", { pid: 201 });
|
|
173
|
+
writeRecord(dir, "claimer-2", { pid: 202 });
|
|
174
|
+
writeRecord(dir, "claimer-3", { pid: 203 });
|
|
175
|
+
const table: Record<number, Liveness> = { 100: "dead", 201: "alive", 202: "alive", 203: "alive", 301: "alive", 302: "alive" };
|
|
176
|
+
writeRecord(dir, "taker-a", { pid: 301 });
|
|
177
|
+
writeRecord(dir, "taker-b", { pid: 302 });
|
|
178
|
+
|
|
179
|
+
const first = ownershipFor("claimer-1", dir, table);
|
|
180
|
+
assert.equal(first.ownership.owns(SESSION, "dead-owner", "run-1"), true);
|
|
181
|
+
// While the first claimer lives nobody else may take the result.
|
|
182
|
+
assert.equal(ownershipFor("taker-a", dir, table).ownership.owns(SESSION, "dead-owner", "run-1"), false);
|
|
183
|
+
|
|
184
|
+
table[201] = "dead";
|
|
185
|
+
const a = ownershipFor("taker-a", dir, table, SESSION, { livenessTtlMs: 0 });
|
|
186
|
+
const b = ownershipFor("taker-b", dir, table, SESSION, { livenessTtlMs: 0 });
|
|
187
|
+
assert.equal(a.ownership.canTakeOver(SESSION, "dead-owner", "run-1"), true, "peek never claims");
|
|
188
|
+
assert.deepEqual(claimFiles(dir), ["run-1.json"]);
|
|
189
|
+
assert.equal(a.ownership.owns(SESSION, "dead-owner", "run-1"), true);
|
|
190
|
+
assert.equal(b.ownership.owns(SESSION, "dead-owner", "run-1"), false, "the second re-claimer loses");
|
|
191
|
+
assert.deepEqual(claimFiles(dir), ["run-1%g2.json", "run-1.json"].sort());
|
|
192
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, "claims", "run-1%g2.json"), "utf-8")).claimedBy, "taker-a");
|
|
193
|
+
|
|
194
|
+
// A claimer whose record is gone cannot be proven dead.
|
|
195
|
+
fs.rmSync(ownerRecordPath(dir, "taker-a")!);
|
|
196
|
+
assert.equal(ownershipFor("claimer-2", dir, table, SESSION, { livenessTtlMs: 0 }).ownership.owns(SESSION, "dead-owner", "run-1"), false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("gives up once the claim chain is exhausted", () => {
|
|
200
|
+
const dir = tempDir();
|
|
201
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
202
|
+
const table: Record<number, Liveness> = { 100: "dead" };
|
|
203
|
+
const claimers = ["c1", "c2", "c3", "c4"];
|
|
204
|
+
claimers.forEach((id, index) => {
|
|
205
|
+
writeRecord(dir, id, { pid: 200 + index });
|
|
206
|
+
table[200 + index] = "alive";
|
|
207
|
+
});
|
|
208
|
+
for (const id of claimers) {
|
|
209
|
+
assert.equal(ownershipFor(id, dir, table, SESSION, { livenessTtlMs: 0 }).ownership.owns(SESSION, "dead-owner", "run-1"), true, id);
|
|
210
|
+
table[200 + claimers.indexOf(id)] = "dead";
|
|
211
|
+
}
|
|
212
|
+
writeRecord(dir, "late", { pid: 300 });
|
|
213
|
+
table[300] = "alive";
|
|
214
|
+
assert.equal(ownershipFor("late", dir, table, SESSION, { livenessTtlMs: 0 }).ownership.owns(SESSION, "dead-owner", "run-1"), false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("fails safe on an unreadable claim marker", () => {
|
|
218
|
+
const dir = tempDir();
|
|
219
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
220
|
+
fs.mkdirSync(path.join(dir, "claims"), { recursive: true });
|
|
221
|
+
fs.writeFileSync(path.join(dir, "claims", "run-1.json"), "{half");
|
|
222
|
+
const { ownership } = ownershipFor("me", dir, { 100: "dead" });
|
|
223
|
+
assert.equal(ownership.owns(SESSION, "dead-owner", "run-1"), false);
|
|
224
|
+
assert.equal(ownership.canTakeOver(SESSION, "dead-owner", "run-1"), false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps run keys from escaping the claims directory", () => {
|
|
228
|
+
const dir = tempDir();
|
|
229
|
+
writeRecord(dir, "dead-owner", { pid: 100 });
|
|
230
|
+
const { ownership } = ownershipFor("me", dir, { 100: "dead" });
|
|
231
|
+
assert.equal(ownership.owns(SESSION, "dead-owner", "../../escape"), true);
|
|
232
|
+
assert.deepEqual(claimFiles(dir), [`${encodeURIComponent("../../escape")}.json`]);
|
|
233
|
+
assert.equal(fs.existsSync(path.join(dir, "..", "escape.json")), false);
|
|
234
|
+
assert.equal(ownerFileStem("../x/y"), "..%2Fx%2Fy");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("probes each owner at most once per TTL window", () => {
|
|
238
|
+
const dir = tempDir();
|
|
239
|
+
writeRecord(dir, "live-owner", { pid: 100 });
|
|
240
|
+
let probes = 0;
|
|
241
|
+
let clock = 10_000;
|
|
242
|
+
const { ownership } = ownershipFor("me", dir, {}, SESSION, {
|
|
243
|
+
now: () => clock,
|
|
244
|
+
processLiveness: () => { probes += 1; return "alive"; },
|
|
245
|
+
});
|
|
246
|
+
for (let index = 0; index < 50; index += 1) ownership.owns(SESSION, "live-owner", `run-${index}`);
|
|
247
|
+
assert.equal(probes, 1);
|
|
248
|
+
clock += 1_999;
|
|
249
|
+
ownership.owns(SESSION, "live-owner", "run-x");
|
|
250
|
+
assert.equal(probes, 1);
|
|
251
|
+
clock += 2;
|
|
252
|
+
ownership.owns(SESSION, "live-owner", "run-y");
|
|
253
|
+
assert.equal(probes, 2);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("garbage-collects only provably dead, week-old owner records and stale claims", () => {
|
|
257
|
+
const dir = tempDir();
|
|
258
|
+
const week = 8 * 24 * 60 * 60 * 1000;
|
|
259
|
+
const old = new Date(Date.now() - week);
|
|
260
|
+
const table: Record<number, Liveness> = { 1: "dead", 2: "alive", 3: "dead", 4: "unknown" };
|
|
261
|
+
writeRecord(dir, "old-dead", { pid: 1 });
|
|
262
|
+
writeRecord(dir, "old-alive", { pid: 2 });
|
|
263
|
+
writeRecord(dir, "young-dead", { pid: 3 });
|
|
264
|
+
writeRecord(dir, "old-unknown", { pid: 4 });
|
|
265
|
+
writeRecord(dir, "me", { pid: 5 });
|
|
266
|
+
table[5] = "dead";
|
|
267
|
+
for (const id of ["old-dead", "old-alive", "old-unknown", "me"]) fs.utimesSync(ownerRecordPath(dir, id)!, old, old);
|
|
268
|
+
fs.mkdirSync(path.join(dir, "claims"));
|
|
269
|
+
fs.writeFileSync(path.join(dir, "claims", "gone.json"), JSON.stringify({ claimedBy: "young-dead", previousOwner: "x", claimedAt: 1 }));
|
|
270
|
+
fs.utimesSync(path.join(dir, "claims", "gone.json"), old, old);
|
|
271
|
+
fs.writeFileSync(path.join(dir, "claims", "kept.json"), JSON.stringify({ claimedBy: "old-alive", previousOwner: "x", claimedAt: 1 }));
|
|
272
|
+
fs.utimesSync(path.join(dir, "claims", "kept.json"), old, old);
|
|
273
|
+
fs.writeFileSync(path.join(dir, "claims", "stale.json.1.abc.tmp"), "x");
|
|
274
|
+
fs.utimesSync(path.join(dir, "claims", "stale.json.1.abc.tmp"), old, old);
|
|
275
|
+
writeRecord(dir, "dead-owner", { pid: 9 });
|
|
276
|
+
table[9] = "dead";
|
|
277
|
+
const { ownership } = ownershipFor("me", dir, table);
|
|
278
|
+
ownership.owns(SESSION, "dead-owner", "run-1");
|
|
279
|
+
const left = fs.readdirSync(dir).sort();
|
|
280
|
+
assert.deepEqual(left, ["claims", "dead-owner.json", "me.json", "old-alive.json", "old-unknown.json", "young-dead.json"].sort());
|
|
281
|
+
assert.deepEqual(claimFiles(dir), ["kept.json", "run-1.json"]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("takes over from a real exited process, identified by its real pid", () => {
|
|
285
|
+
const dir = tempDir();
|
|
286
|
+
const child = spawnSync(process.execPath, ["-e", "process.stdout.write(String(process.pid))"], { encoding: "utf-8" });
|
|
287
|
+
const pid = Number(child.stdout);
|
|
288
|
+
assert.ok(Number.isSafeInteger(pid) && pid > 0);
|
|
289
|
+
writeRecord(dir, "exited", { pid, hostname: os.hostname() });
|
|
290
|
+
writeRecord(dir, "running", { pid: process.pid, hostname: os.hostname() });
|
|
291
|
+
const state = { currentSessionId: SESSION as string | null, completionOwnerId: "me" };
|
|
292
|
+
const ownership = createResultDeliveryOwnership(state, { ownersDir: dir });
|
|
293
|
+
assert.equal(ownership.owns(SESSION, "exited", "run-1"), true);
|
|
294
|
+
assert.equal(ownership.owns(SESSION, "running", "run-2"), false, "this very process is alive");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("owner record", () => {
|
|
299
|
+
it("is written once per process, shared across reloads, and survives shutdown", () => {
|
|
300
|
+
const dir = tempDir();
|
|
301
|
+
const ids = { completionOwnerId: "owner-xyz", pid: 777, hostname: HOST, startKey: () => "ps:start" };
|
|
302
|
+
assert.equal(ensureOwnerRecord({ ownersDir: dir, ...ids, now: () => 111 }), true);
|
|
303
|
+
const file = ownerRecordPath(dir, "owner-xyz")!;
|
|
304
|
+
const first = fs.readFileSync(file, "utf-8");
|
|
305
|
+
assert.deepEqual(JSON.parse(first), { version: 1, completionOwnerId: "owner-xyz", pid: 777, startKey: "ps:start", hostname: HOST, createdAt: 111 });
|
|
306
|
+
// Simulated extension reload: same owner id, later clock.
|
|
307
|
+
let startKeyCalls = 0;
|
|
308
|
+
assert.equal(ensureOwnerRecord({ ownersDir: dir, ...ids, startKey: () => { startKeyCalls += 1; return "x"; }, now: () => 999 }), false);
|
|
309
|
+
assert.equal(fs.readFileSync(file, "utf-8"), first);
|
|
310
|
+
assert.equal(startKeyCalls, 0, "a reload must not spawn ps again");
|
|
311
|
+
assert.deepEqual(fs.readdirSync(dir), ["owner-xyz.json"], "no temp files left behind");
|
|
312
|
+
if (process.platform !== "win32") assert.equal(fs.statSync(file).mode & 0o077, 0, "record is private to the user");
|
|
313
|
+
// Not removed by anything this process does on shutdown: detached runners outlive it and need the record.
|
|
314
|
+
assert.deepEqual(readOwnerRecord(dir, "owner-xyz")?.pid, 777);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("replaces a record left by a different process that reused the owner id slot", () => {
|
|
318
|
+
const dir = tempDir();
|
|
319
|
+
ensureOwnerRecord({ ownersDir: dir, completionOwnerId: "o", pid: 1, hostname: HOST, startKey: () => undefined, now: () => 1 });
|
|
320
|
+
assert.equal(ensureOwnerRecord({ ownersDir: dir, completionOwnerId: "o", pid: 2, hostname: HOST, startKey: () => undefined, now: () => 2 }), true);
|
|
321
|
+
assert.equal(readOwnerRecord(dir, "o")?.pid, 2);
|
|
322
|
+
assert.equal("startKey" in (readOwnerRecord(dir, "o") ?? {}), false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("resolves this process's real liveness and start key", () => {
|
|
326
|
+
assert.equal(processLiveness(process.pid), "alive");
|
|
327
|
+
assert.equal(processLiveness(0), "unknown");
|
|
328
|
+
assert.equal(processLiveness(-5), "unknown");
|
|
329
|
+
const key = processStartKey(process.pid);
|
|
330
|
+
assert.equal(processStartKey(process.pid), key, "own key is cached");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -791,9 +791,141 @@ export function formatTokenInUsage(usage: TokenInUsage): string {
|
|
|
791
791
|
].join("\n");
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
+
export interface TokenInBalanceBucket {
|
|
795
|
+
kind: string;
|
|
796
|
+
planId?: string;
|
|
797
|
+
amountUsd: number;
|
|
798
|
+
usedUsd: number;
|
|
799
|
+
remainingUsd: number;
|
|
800
|
+
state: "active" | "upcoming" | "expired";
|
|
801
|
+
expiresAt?: string;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export interface TokenInBalance {
|
|
805
|
+
availableUsd: number;
|
|
806
|
+
fixedAvailableUsd: number;
|
|
807
|
+
paygAvailableUsd: number;
|
|
808
|
+
debtUsd: number;
|
|
809
|
+
asOf?: string;
|
|
810
|
+
buckets: TokenInBalanceBucket[];
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const finiteNumber = (value: unknown): number | undefined => {
|
|
814
|
+
const n = typeof value === "number" ? value : Number.NaN;
|
|
815
|
+
return Number.isFinite(n) ? n : undefined;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
/** Parse a /tokenin/account/me/balance payload. Exported for tests. */
|
|
819
|
+
export function parseTokenInBalance(payload: unknown): TokenInBalance | undefined {
|
|
820
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
821
|
+
const obj = payload as Record<string, unknown>;
|
|
822
|
+
const availableUsd = finiteNumber(obj.available_usd);
|
|
823
|
+
if (availableUsd === undefined || !Array.isArray(obj.buckets)) return undefined;
|
|
824
|
+
const buckets: TokenInBalanceBucket[] = [];
|
|
825
|
+
for (const raw of obj.buckets) {
|
|
826
|
+
if (!raw || typeof raw !== "object") continue;
|
|
827
|
+
const b = raw as Record<string, unknown>;
|
|
828
|
+
const state = b.state;
|
|
829
|
+
if (state !== "active" && state !== "upcoming" && state !== "expired") continue;
|
|
830
|
+
const bucket: TokenInBalanceBucket = {
|
|
831
|
+
kind: typeof b.kind === "string" ? b.kind : "unknown",
|
|
832
|
+
amountUsd: finiteNumber(b.amount_usd) ?? 0,
|
|
833
|
+
usedUsd: finiteNumber(b.used_usd) ?? 0,
|
|
834
|
+
remainingUsd: finiteNumber(b.remaining_usd) ?? 0,
|
|
835
|
+
state,
|
|
836
|
+
};
|
|
837
|
+
if (typeof b.plan_id === "string") bucket.planId = b.plan_id;
|
|
838
|
+
if (typeof b.expires_at === "string") bucket.expiresAt = b.expires_at;
|
|
839
|
+
buckets.push(bucket);
|
|
840
|
+
}
|
|
841
|
+
const balance: TokenInBalance = {
|
|
842
|
+
availableUsd,
|
|
843
|
+
fixedAvailableUsd: finiteNumber(obj.fixed_available_usd) ?? 0,
|
|
844
|
+
paygAvailableUsd: finiteNumber(obj.payg_available_usd) ?? 0,
|
|
845
|
+
debtUsd: finiteNumber(obj.debt_usd) ?? 0,
|
|
846
|
+
buckets,
|
|
847
|
+
};
|
|
848
|
+
if (typeof obj.as_of === "string") balance.asOf = obj.as_of;
|
|
849
|
+
return balance;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function formatTimeUntil(iso: string): string | undefined {
|
|
853
|
+
const d = new Date(iso);
|
|
854
|
+
if (Number.isNaN(d.getTime())) return undefined;
|
|
855
|
+
const ms = d.getTime() - Date.now();
|
|
856
|
+
if (ms <= 0) return undefined;
|
|
857
|
+
const days = Math.floor(ms / 86_400_000);
|
|
858
|
+
const hours = Math.floor((ms % 86_400_000) / 3_600_000);
|
|
859
|
+
return days > 0 ? `${days}d ${hours}h` : `${Math.max(1, Math.ceil(ms / 3_600_000))}h`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** Format an account balance as a boxed display. Exported for tests. */
|
|
863
|
+
export function formatTokenInBalance(balance: TokenInBalance): string {
|
|
864
|
+
const money = (v: number) => `$${v.toFixed(2)}`;
|
|
865
|
+
const active = balance.buckets.filter((b) => b.state === "active");
|
|
866
|
+
const total = active.reduce((sum, b) => sum + b.amountUsd, 0);
|
|
867
|
+
const used = active.reduce((sum, b) => sum + b.usedUsd, 0);
|
|
868
|
+
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
|
|
869
|
+
const barWidth = 40;
|
|
870
|
+
const filled = total > 0 ? Math.min(barWidth, Math.round((pct / 100) * barWidth)) : 0;
|
|
871
|
+
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
|
|
872
|
+
const pctText = total > 0 ? `${pct.toFixed(1)}%` : "—";
|
|
873
|
+
|
|
874
|
+
const summary = `Available ${money(balance.availableUsd)}`;
|
|
875
|
+
let split = `Plan ${money(balance.fixedAvailableUsd)} · PAYG ${money(balance.paygAvailableUsd)}`;
|
|
876
|
+
if (balance.debtUsd > 0) split += ` · Debt ${money(balance.debtUsd)}`;
|
|
877
|
+
const usedLine = total > 0 ? `Used ${money(used)} of ${money(total)} active credit` : "No active credit";
|
|
878
|
+
|
|
879
|
+
const expiries = active
|
|
880
|
+
.map((b) => b.expiresAt)
|
|
881
|
+
.filter((v): v is string => typeof v === "string")
|
|
882
|
+
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
|
|
883
|
+
const until = expiries.length > 0 ? formatTimeUntil(expiries[0]) : undefined;
|
|
884
|
+
const footer = until ? `Plan credit expires in ${until}` : "";
|
|
885
|
+
|
|
886
|
+
const W = 50;
|
|
887
|
+
const inner = W - 4;
|
|
888
|
+
const pad = (s: string) => s + " ".repeat(Math.max(0, inner - s.length));
|
|
889
|
+
const title = "╭─ Token-In balance ";
|
|
890
|
+
const top = `${title}${"─".repeat(W - title.length - 1)}╮`;
|
|
891
|
+
const bottom = `╰${"─".repeat(W - 2)}╯`;
|
|
892
|
+
const lines = [top, `│ ${pad(summary)} │`, `│ ${pad(split)} │`, `│ ${pad(usedLine)} │`, `│ ${bar} ${pctText.padStart(5)} │`];
|
|
893
|
+
if (footer) lines.push(`│ ${pad(footer)} │`);
|
|
894
|
+
lines.push(bottom);
|
|
895
|
+
return lines.join("\n");
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function tokenInProxyRoot(baseUrl: string): string {
|
|
899
|
+
return baseUrl.replace(/\/v1\/?$/, "");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Query the self-scoped account balance with the user's own key. Returns undefined when
|
|
904
|
+
* the gateway has no account balance for this key (404: older gateway, accounts disabled,
|
|
905
|
+
* or a legacy key) or answers with something that is not a balance, so callers can fall
|
|
906
|
+
* back to /key/info. Other errors throw. Exported for tests.
|
|
907
|
+
*/
|
|
908
|
+
export async function fetchTokenInBalance(apiKey: string, baseUrl: string): Promise<TokenInBalance | undefined> {
|
|
909
|
+
const response = await fetch(`${tokenInProxyRoot(baseUrl)}/tokenin/account/me/balance`, {
|
|
910
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
911
|
+
signal: AbortSignal.timeout(5_000),
|
|
912
|
+
});
|
|
913
|
+
if (response.status === 404) return undefined;
|
|
914
|
+
if (!response.ok) {
|
|
915
|
+
throw new Error(`Token-In usage endpoint returned HTTP ${response.status}`);
|
|
916
|
+
}
|
|
917
|
+
let payload: unknown;
|
|
918
|
+
try {
|
|
919
|
+
payload = await response.json();
|
|
920
|
+
} catch {
|
|
921
|
+
return undefined;
|
|
922
|
+
}
|
|
923
|
+
return parseTokenInBalance(payload);
|
|
924
|
+
}
|
|
925
|
+
|
|
794
926
|
/** Query LiteLLM /key/info at the proxy root with the account key. Exported for tests. */
|
|
795
927
|
export async function fetchTokenInUsage(apiKey: string, baseUrl: string): Promise<TokenInUsage> {
|
|
796
|
-
const root = baseUrl
|
|
928
|
+
const root = tokenInProxyRoot(baseUrl);
|
|
797
929
|
const response = await fetch(`${root}/key/info`, {
|
|
798
930
|
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
799
931
|
signal: AbortSignal.timeout(5_000),
|
|
@@ -906,6 +1038,11 @@ async function handleTokenInUsage(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
906
1038
|
const account = auth.accounts.find((a) => a.id === activeId);
|
|
907
1039
|
const baseUrl = account?.baseUrl ?? getTokenInBaseUrl(readModelsJson()) ?? TOKEN_IN_DEFAULT_BASE_URL;
|
|
908
1040
|
try {
|
|
1041
|
+
const balance = await fetchTokenInBalance(apiKey, baseUrl);
|
|
1042
|
+
if (balance) {
|
|
1043
|
+
ctx.ui.notify(formatTokenInBalance(balance), "info");
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
909
1046
|
const usage = await fetchTokenInUsage(apiKey, baseUrl);
|
|
910
1047
|
ctx.ui.notify(formatTokenInUsage(usage), "info");
|
|
911
1048
|
} catch (err) {
|
|
@@ -965,8 +1102,10 @@ async function runOnboarding(pi: ExtensionAPI, ctx: ExtensionContext): Promise<v
|
|
|
965
1102
|
return;
|
|
966
1103
|
}
|
|
967
1104
|
|
|
968
|
-
// No UI? Defer to a later session.
|
|
969
|
-
|
|
1105
|
+
// No UI, or a headless RPC host? Defer to a later session. In RPC mode a startup
|
|
1106
|
+
// confirm blocks the whole command loop (activation is awaited before stdin is served),
|
|
1107
|
+
// and an unattended host may never answer it; onboarding stays available via /tokenin add.
|
|
1108
|
+
if (!ctx.hasUI || ctx.mode === "rpc") {
|
|
970
1109
|
return;
|
|
971
1110
|
}
|
|
972
1111
|
|
package/dist/index.d.ts
CHANGED
|
@@ -26,7 +26,7 @@ export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from ".
|
|
|
26
26
|
export { type BashOperations, type BashSpawnContext, type BashSpawnHook, type BashToolDetails, type BashToolInput, type BashToolOptions, createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLocalBashOperations, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type EditOperations, type EditToolDetails, type EditToolInput, type EditToolOptions, type FindOperations, type FindToolDetails, type FindToolInput, type FindToolOptions, formatSize, type GrepOperations, type GrepToolDetails, type GrepToolInput, type GrepToolOptions, type LsOperations, type LsToolDetails, type LsToolInput, type LsToolOptions, type ReadOperations, type ReadToolDetails, type ReadToolInput, type ReadToolOptions, type ToolsOptions, type TruncationOptions, type TruncationResult, truncateHead, truncateLine, truncateTail, type WriteOperations, type WriteToolInput, type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts";
|
|
27
27
|
export { hasTrustRequiringProjectResources, type ProjectTrustDecision, ProjectTrustStore, type ProjectTrustStoreEntry, type ProjectTrustUpdate, } from "./core/trust-manager.ts";
|
|
28
28
|
export { type MainOptions, main } from "./main.ts";
|
|
29
|
-
export { InteractiveMode, type InteractiveModeOptions, type ModelInfo, type PrintModeOptions, RpcClient, type RpcClientOptions, type RpcCommand, type RpcEventListener, type RpcExtensionUIRequest, type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, runPrintMode, runRpcMode, } from "./modes/index.ts";
|
|
29
|
+
export { InteractiveMode, type InteractiveModeOptions, type ModelInfo, type PrintModeOptions, RpcClient, type RpcClientOptions, type RpcCommand, type RpcEvent, type SubagentRpcCommand, type SubagentRpcEvent, type SubagentRpcResponse, type SubagentRpcSnapshot, type SubagentRpcNode, type SubagentRpcFleet, type SubagentRpcTokens, type SubagentRpcLifecycle, type RpcObservationScope, type ReleaseCommand, type ReleaseResponse, type ReleaseResult, type ReleaseReadinessCommand, type ReleaseReadinessResponse, type ReleaseReadiness, type StopAllBackgroundCommand, type StopAllBackgroundResponse, type StopAllBackgroundResult, type StopAllBackgroundItem, type IntercomRpcCommand, type IntercomRpcResponse, type IntercomRpcEvent, type IntercomRpcStatus, type IntercomRpcDelivery, type IntercomRpcMessageOptions, type IntercomRpcDataMap, type RpcEventListener, type RpcExtensionUIRequest, type RpcExtensionUIResponse, type RpcResponse, type RpcSessionState, runPrintMode, runRpcMode, } from "./modes/index.ts";
|
|
30
30
|
export { ArminComponent, AssistantMessageComponent, BashExecutionComponent, BorderedLoader, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomEditor, type CustomEditorOptions, CustomMessageComponent, DynamicBorder, ExtensionEditorComponent, ExtensionInputComponent, ExtensionSelectorComponent, FooterComponent, keyHint, keyText, LoginDialogComponent, ModelSelectorComponent, OAuthSelectorComponent, type RenderDiffOptions, rawKeyHint, renderDiff, SessionSelectorComponent, type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent, ShowImagesSelectorComponent, SkillInvocationMessageComponent, ThemeSelectorComponent, ThinkingSelectorComponent, ToolExecutionComponent, type ToolExecutionOptions, TreeSelectorComponent, truncateToVisualLines, UserMessageComponent, UserMessageSelectorComponent, type VisualTruncateResult, } from "./modes/interactive/components/index.ts";
|
|
31
31
|
export { getLanguageFromPath, getMarkdownTheme, getSelectListTheme, getSettingsListTheme, highlightCode, initTheme, Theme, type ThemeColor, } from "./modes/interactive/theme/theme.ts";
|
|
32
32
|
export { copyToClipboard } from "./utils/clipboard.ts";
|
|
@@ -35,3 +35,8 @@ export { convertToPng } from "./utils/image-convert.ts";
|
|
|
35
35
|
export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts";
|
|
36
36
|
export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.ts";
|
|
37
37
|
export { getShellConfig } from "./utils/shell.ts";
|
|
38
|
+
export { INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, INTERCOM_RPC_EVENT, INTERCOM_RPC_ASK_TIMEOUT_MS, intercomRpcTimeout, type IntercomRpcRequest, type IntercomRpcResult, } from "./core/intercom-rpc.ts";
|
|
39
|
+
export { SUBAGENT_OBSERVE_EVENT, SUBAGENT_RPC_MAX_BYTES, type SubagentObserveRequest, type SubagentObservationSource, type SubagentRpcProjection } from "./core/subagent-rpc.ts";
|
|
40
|
+
export type { IntercomRpcEventData } from "./core/intercom-rpc.ts";
|
|
41
|
+
export { RELEASE_READINESS_EVENT, answerReleaseReadiness, isReleaseBlocker, type ReleaseBlocker, type ReleaseBlockerSource, type ReleaseReadinessRequest } from "./core/release-rpc.ts";
|
|
42
|
+
export { STOP_ALL_BACKGROUND_EVENT, STOP_ALL_BACKGROUND_TIMEOUT_MS, answerStopAllBackground, isStopAllBackgroundItem, stopAllContributorError, type StopAllBackgroundRequest } from "./core/release-rpc.ts";
|
package/dist/index.js
CHANGED
|
@@ -46,3 +46,10 @@ export { formatDimensionNote, resizeImage } from "./utils/image-resize.js";
|
|
|
46
46
|
export { detectSupportedImageMimeTypeFromFile } from "./utils/mime.js";
|
|
47
47
|
// Shell utilities
|
|
48
48
|
export { getShellConfig } from "./utils/shell.js";
|
|
49
|
+
// Dedicated Intercom host/extension bridge (not a generic RPC event bus).
|
|
50
|
+
export { INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, INTERCOM_RPC_EVENT, INTERCOM_RPC_ASK_TIMEOUT_MS, intercomRpcTimeout, } from "./core/intercom-rpc.js";
|
|
51
|
+
export { SUBAGENT_OBSERVE_EVENT, SUBAGENT_RPC_MAX_BYTES } from "./core/subagent-rpc.js";
|
|
52
|
+
// Release-readiness broadcast: extensions contribute blockers; the RPC host aggregates them.
|
|
53
|
+
export { RELEASE_READINESS_EVENT, answerReleaseReadiness, isReleaseBlocker } from "./core/release-rpc.js";
|
|
54
|
+
// Kill switch (`stop_all_background`): extensions register a stop handler next to their readiness contributor.
|
|
55
|
+
export { STOP_ALL_BACKGROUND_EVENT, STOP_ALL_BACKGROUND_TIMEOUT_MS, answerStopAllBackground, isStopAllBackgroundItem, stopAllContributorError } from "./core/release-rpc.js";
|
package/dist/main.d.ts
CHANGED
|
@@ -7,5 +7,11 @@
|
|
|
7
7
|
import type { InlineExtension } from "./core/extensions/types.ts";
|
|
8
8
|
export interface MainOptions {
|
|
9
9
|
extensionFactories?: InlineExtension[];
|
|
10
|
+
/**
|
|
11
|
+
* Built-in extensions arrive compiled in through `extensionFactories` (native
|
|
12
|
+
* binary), so the on-disk bundled extensions dir is shipped only for its
|
|
13
|
+
* assets and must not be loaded a second time.
|
|
14
|
+
*/
|
|
15
|
+
builtinExtensionsCompiled?: boolean;
|
|
10
16
|
}
|
|
11
17
|
export declare function main(args: string[], options?: MainOptions): Promise<void>;
|
package/dist/main.js
CHANGED
|
@@ -545,7 +545,7 @@ export async function main(args, options) {
|
|
|
545
545
|
const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);
|
|
546
546
|
const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);
|
|
547
547
|
// Bundled Selesai resources ship in dist and load through the same hooks as CLI paths.
|
|
548
|
-
const bundledExtensionPaths = [getBundledExtensionsDir()];
|
|
548
|
+
const bundledExtensionPaths = options?.builtinExtensionsCompiled ? [] : [getBundledExtensionsDir()];
|
|
549
549
|
const bundledThemePaths = [getBundledThemesDir()];
|
|
550
550
|
const bundledSkillPaths = [getBundledSkillsDir()];
|
|
551
551
|
const createRuntime = async ({ cwd, agentDir, sessionManager, sessionStartEvent, projectTrustContext, }) => {
|
package/dist/modes/index.d.ts
CHANGED
|
@@ -5,4 +5,4 @@ export { InteractiveMode, type InteractiveModeOptions } from "./interactive/inte
|
|
|
5
5
|
export { type PrintModeOptions, runPrintMode } from "./print-mode.ts";
|
|
6
6
|
export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client.ts";
|
|
7
7
|
export { runRpcMode } from "./rpc/rpc-mode.ts";
|
|
8
|
-
export type { RpcCommand, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, RpcSessionState, PrototypePhase, QuicktypePhase, WorkflowState, } from "./rpc/rpc-types.ts";
|
|
8
|
+
export type { RpcCommand, RpcEvent, SubagentRpcCommand, SubagentRpcEvent, SubagentRpcResponse, SubagentRpcSnapshot, SubagentRpcNode, SubagentRpcFleet, SubagentRpcTokens, SubagentRpcLifecycle, RpcObservationScope, ReleaseCommand, ReleaseResponse, ReleaseResult, ReleaseReadinessCommand, ReleaseReadinessResponse, ReleaseReadiness, StopAllBackgroundCommand, StopAllBackgroundResponse, StopAllBackgroundResult, StopAllBackgroundItem, IntercomRpcCommand, IntercomRpcResponse, IntercomRpcEvent, IntercomRpcStatus, IntercomRpcDelivery, IntercomRpcMessageOptions, IntercomRpcDataMap, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, RpcSessionState, PrototypePhase, QuicktypePhase, WorkflowState, } from "./rpc/rpc-types.ts";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EventBus } from "../../core/event-bus.ts";
|
|
2
|
+
import { type IntercomRpcCommand, type IntercomRpcEvent } from "../../core/intercom-rpc.ts";
|
|
3
|
+
import type { RpcResponse } from "./rpc-types.ts";
|
|
4
|
+
/** One listener pair per bound session; replacement rejects pending calls and ignores stale results. */
|
|
5
|
+
export declare class RpcIntercomBridge {
|
|
6
|
+
private disposed;
|
|
7
|
+
private readonly unsubscribe;
|
|
8
|
+
private readonly pending;
|
|
9
|
+
private readonly scope;
|
|
10
|
+
private readonly bus;
|
|
11
|
+
constructor(bus: EventBus | undefined, output: (event: IntercomRpcEvent) => void, sessionId: string, epoch?: string);
|
|
12
|
+
request(command: IntercomRpcCommand): Promise<RpcResponse>;
|
|
13
|
+
dispose(): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { INTERCOM_RPC_EVENT, INTERCOM_RPC_REQUEST_EVENT, INTERCOM_RPC_RESULT_EVENT, intercomRpcTimeout, isIntercomRpcEvent, } from "../../core/intercom-rpc.js";
|
|
3
|
+
/** One listener pair per bound session; replacement rejects pending calls and ignores stale results. */
|
|
4
|
+
export class RpcIntercomBridge {
|
|
5
|
+
disposed = false;
|
|
6
|
+
unsubscribe = [];
|
|
7
|
+
pending = new Map();
|
|
8
|
+
scope;
|
|
9
|
+
bus;
|
|
10
|
+
constructor(bus, output, sessionId, epoch = randomUUID()) {
|
|
11
|
+
this.scope = { version: 1, sessionId, epoch };
|
|
12
|
+
this.bus = bus;
|
|
13
|
+
if (!bus)
|
|
14
|
+
return;
|
|
15
|
+
this.unsubscribe.push(bus.on(INTERCOM_RPC_EVENT, (event) => {
|
|
16
|
+
const envelope = event;
|
|
17
|
+
if (!this.disposed && envelope?.sessionId === sessionId && isIntercomRpcEvent(envelope.event))
|
|
18
|
+
output({ ...envelope.event, scope: this.scope });
|
|
19
|
+
}));
|
|
20
|
+
this.unsubscribe.push(bus.on(INTERCOM_RPC_RESULT_EVENT, (result) => {
|
|
21
|
+
if (this.disposed)
|
|
22
|
+
return;
|
|
23
|
+
const value = result;
|
|
24
|
+
if (!value || typeof value.requestId !== "string" || typeof value.success !== "boolean")
|
|
25
|
+
return;
|
|
26
|
+
this.pending.get(value.requestId)?.finish(value);
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
request(command) {
|
|
30
|
+
const failure = (error) => ({ id: command.id, type: "response", command: command.type, success: false, error });
|
|
31
|
+
if (this.disposed)
|
|
32
|
+
return Promise.resolve(failure("Intercom session ended"));
|
|
33
|
+
if (!this.bus)
|
|
34
|
+
return Promise.resolve(failure("Intercom extension unavailable"));
|
|
35
|
+
let timeoutMs;
|
|
36
|
+
try {
|
|
37
|
+
timeoutMs = intercomRpcTimeout(command);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return Promise.resolve(failure(error.message));
|
|
41
|
+
}
|
|
42
|
+
const requestId = randomUUID();
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
this.pending.get(requestId)?.finish({ requestId, success: false, error: "Intercom request timed out; timeout is not cancellation of delivered messages" });
|
|
47
|
+
controller.abort();
|
|
48
|
+
}, timeoutMs + 1000);
|
|
49
|
+
this.pending.set(requestId, {
|
|
50
|
+
controller,
|
|
51
|
+
finish: (result) => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
this.pending.delete(requestId);
|
|
54
|
+
resolve(result.success
|
|
55
|
+
? { id: command.id, type: "response", command: command.type, success: true, data: command.type === "intercom_status" ? { ...result.data, scope: this.scope } : result.data }
|
|
56
|
+
: failure(result.error));
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
let accepted = false;
|
|
60
|
+
const request = { requestId, command, signal: controller.signal, accept: () => { accepted = true; } };
|
|
61
|
+
this.bus.emit(INTERCOM_RPC_REQUEST_EVENT, request);
|
|
62
|
+
if (!accepted)
|
|
63
|
+
this.pending.get(requestId)?.finish({ requestId, success: false, error: "Intercom extension unavailable" });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
dispose() {
|
|
67
|
+
this.disposed = true;
|
|
68
|
+
for (const unsubscribe of this.unsubscribe)
|
|
69
|
+
unsubscribe();
|
|
70
|
+
for (const [requestId, pending] of [...this.pending]) {
|
|
71
|
+
pending.finish({ requestId, success: false, error: "Intercom session ended" });
|
|
72
|
+
pending.controller.abort();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|