@saadnvd1/agent-os 0.3.499 → 0.3.502
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 +77 -0
- package/app/api/usage/rollups/route.ts +36 -0
- package/components/Chat/Activity.test.ts +22 -0
- package/components/Chat/Activity.tsx +17 -5
- package/components/Chat/ChatPanel.tsx +7 -1
- package/components/Devices/ConnectCard.tsx +10 -12
- package/components/SessionList/RowParts.tsx +13 -4
- package/components/SessionList/SessionList.types.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Usage/RatchetView.tsx +249 -0
- package/components/Usage/UsageDialog.tsx +7 -1
- package/components/Usage/WindowCard.tsx +2 -1
- package/components/views/types.ts +2 -0
- package/data/chat/useChat.ts +7 -0
- package/data/usage/index.ts +28 -0
- package/lib/chat/activity.ts +24 -2
- package/lib/chat/drivers/claude-mapper.ts +8 -0
- package/lib/chat/drivers/claude.ts +46 -0
- package/lib/chat/drivers/codex-mapper.ts +5 -1
- package/lib/chat/drivers/pi-mapper.ts +4 -0
- package/lib/chat/events.ts +15 -1
- package/lib/chat/registry.ts +8 -1
- package/lib/chat/runner.ts +27 -0
- package/lib/chat/worker/host.ts +47 -0
- package/lib/chat/worker/main.ts +1 -0
- package/lib/chat/worker/protocol.ts +14 -1
- package/lib/connect/cli.ts +51 -72
- package/lib/connect/cloudflared.ts +172 -0
- package/lib/connect/config.ts +75 -26
- package/lib/connect/control-api.ts +107 -48
- package/lib/connect/enrol.ts +33 -107
- package/lib/connect/serve.ts +177 -94
- package/lib/connect/supervise.ts +281 -0
- package/lib/db/migrations.ts +72 -0
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +5 -1
- package/lib/security/auth.ts +20 -4
- package/lib/security/gate.ts +8 -5
- package/lib/security/reach.ts +1 -1
- package/lib/sidebar/shelves.ts +7 -0
- package/lib/status/collect.ts +7 -1
- package/lib/tasks/index.ts +8 -0
- package/lib/usage/calls.ts +284 -0
- package/lib/usage/check-runs.ts +40 -19
- package/lib/usage/config.ts +56 -0
- package/lib/usage/nightly.ts +157 -0
- package/lib/usage/quality.ts +239 -0
- package/lib/usage/ratchet.ts +275 -0
- package/lib/usage/rate-limits.ts +192 -0
- package/lib/usage/rollup.ts +90 -0
- package/lib/usage/span.ts +152 -0
- package/lib/usage/transcript-import.ts +226 -0
- package/lib/usage/weights.ts +64 -0
- package/lib/usage/windows.ts +27 -2
- package/package.json +2 -4
- package/server.ts +7 -4
- package/stores/usageUi.ts +3 -3
- package/lib/connect/cert-check.ts +0 -32
- package/lib/connect/cloudflare-dns.ts +0 -56
- package/lib/connect/frames.ts +0 -72
- package/lib/connect/identity.ts +0 -98
- package/lib/connect/mux.ts +0 -110
- package/lib/connect/stream.ts +0 -106
- package/lib/connect/tunnel.ts +0 -98
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps `cloudflared tunnel run` going while Connect is on. Each start asks
|
|
3
|
+
* the Connect service for a fresh connector token, which goes to the child
|
|
4
|
+
* in TUNNEL_TOKEN only: never argv, never a file, never a log line. The
|
|
5
|
+
* child exits → it's started again with backoff; stop() ends it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile, spawn as nodeSpawn } from "child_process";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import readline from "readline";
|
|
12
|
+
import type { Readable } from "stream";
|
|
13
|
+
import { ApiError, clean } from "./control-api";
|
|
14
|
+
|
|
15
|
+
export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
|
|
16
|
+
|
|
17
|
+
export interface Prepared {
|
|
18
|
+
bin: string;
|
|
19
|
+
token: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Child {
|
|
23
|
+
pid?: number;
|
|
24
|
+
stdout: Readable | null;
|
|
25
|
+
stderr: Readable | null;
|
|
26
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
27
|
+
on(event: "exit", fn: (code: number | null) => void): unknown;
|
|
28
|
+
on(event: "error", fn: (err: Error) => void): unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Spawn = (
|
|
32
|
+
bin: string,
|
|
33
|
+
args: string[],
|
|
34
|
+
opts: { env: Record<string, string>; stdio: ["ignore", "pipe", "pipe"] }
|
|
35
|
+
) => Child;
|
|
36
|
+
|
|
37
|
+
export interface SuperviseOptions {
|
|
38
|
+
/** Finds the binary and fetches a fresh token; throws to retry later. */
|
|
39
|
+
prepare: () => Promise<Prepared>;
|
|
40
|
+
/** The Connect folder: cloudflared's config, home and pid file live here. */
|
|
41
|
+
dir: string;
|
|
42
|
+
log?: (line: string) => void;
|
|
43
|
+
spawn?: Spawn;
|
|
44
|
+
delays?: { min: number; max: number; setupMax: number; denied: number };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DELAYS = {
|
|
48
|
+
min: 1000,
|
|
49
|
+
max: 60_000,
|
|
50
|
+
setupMax: 5 * 60_000,
|
|
51
|
+
denied: 5 * 60_000,
|
|
52
|
+
};
|
|
53
|
+
// cloudflared can be chatty while the network flaps.
|
|
54
|
+
const LOG_LINES_PER_MINUTE = 20;
|
|
55
|
+
|
|
56
|
+
/** A cloudflared left running by an AgentOS that died without stopping it. */
|
|
57
|
+
const commandOf = (pid: number) =>
|
|
58
|
+
new Promise<string>((resolve) =>
|
|
59
|
+
execFile(
|
|
60
|
+
"ps",
|
|
61
|
+
["-o", "command=", "-p", String(pid)],
|
|
62
|
+
{ encoding: "utf8", timeout: 5000 },
|
|
63
|
+
(err, out) => resolve(err ? "" : out)
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
async function stopStale(pidFile: string, binDir: string) {
|
|
68
|
+
let pid = 0;
|
|
69
|
+
try {
|
|
70
|
+
pid = Number(fs.readFileSync(pidFile, "utf8")) || 0;
|
|
71
|
+
} catch {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (pid > 1) {
|
|
75
|
+
const command = await commandOf(pid);
|
|
76
|
+
try {
|
|
77
|
+
if (command.startsWith(binDir + path.sep)) process.kill(pid, "SIGTERM");
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
fs.rmSync(pidFile, { force: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function superviseTunnel(opts: SuperviseOptions) {
|
|
84
|
+
const log = opts.log ?? (() => {});
|
|
85
|
+
const spawn = opts.spawn ?? (nodeSpawn as unknown as Spawn);
|
|
86
|
+
const delays = opts.delays ?? DELAYS;
|
|
87
|
+
const pidFile = path.join(opts.dir, "cloudflared.pid");
|
|
88
|
+
const home = path.join(opts.dir, "cloudflared-home");
|
|
89
|
+
const configFile = path.join(opts.dir, "cloudflared.yml");
|
|
90
|
+
|
|
91
|
+
let state: TunnelState = "connecting";
|
|
92
|
+
let reason: string | undefined;
|
|
93
|
+
let stopped = false;
|
|
94
|
+
let child: Child | null = null;
|
|
95
|
+
let timer: NodeJS.Timeout | null = null;
|
|
96
|
+
let delay = delays.min;
|
|
97
|
+
const connections = new Set<string>();
|
|
98
|
+
let logged = { at: 0, n: 0 };
|
|
99
|
+
|
|
100
|
+
const say = (line: string) => {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
if (now - logged.at > 60_000) logged = { at: now, n: 0 };
|
|
103
|
+
if (++logged.n <= LOG_LINES_PER_MINUTE) log(line);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const retry = (wait: number) => {
|
|
107
|
+
if (stopped) return;
|
|
108
|
+
timer = setTimeout(() => {
|
|
109
|
+
timer = null;
|
|
110
|
+
tick();
|
|
111
|
+
}, wait);
|
|
112
|
+
timer.unref?.();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Nothing here may reject: an unhandled rejection takes AgentOS down.
|
|
116
|
+
const tick = () =>
|
|
117
|
+
void attempt().catch(() => {
|
|
118
|
+
state = "error";
|
|
119
|
+
retry(delays.max);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const attempt = async () => {
|
|
123
|
+
if (stopped) return;
|
|
124
|
+
state = "connecting";
|
|
125
|
+
try {
|
|
126
|
+
const prepared = await opts.prepare();
|
|
127
|
+
if (stopped) return;
|
|
128
|
+
await run(prepared);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (stopped) return;
|
|
131
|
+
const status = err instanceof ApiError ? err.status : 0;
|
|
132
|
+
reason = clean((err as Error)?.message ?? String(err));
|
|
133
|
+
log(`connect: ${reason}`);
|
|
134
|
+
if (status === 401 || status === 403) {
|
|
135
|
+
// Revoked or disabled: it won't fix itself in seconds.
|
|
136
|
+
state = "denied";
|
|
137
|
+
retry(delays.denied);
|
|
138
|
+
} else {
|
|
139
|
+
state = "error";
|
|
140
|
+
retry(delay);
|
|
141
|
+
delay = Math.min(delay * 2, delays.setupMax);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const run = async ({ bin, token }: Prepared) => {
|
|
147
|
+
const redact = (line: string) => clean(line.split(token).join("[token]"));
|
|
148
|
+
await stopStale(pidFile, path.dirname(bin));
|
|
149
|
+
if (stopped) return;
|
|
150
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
151
|
+
// Its own config and home, so no config.yml elsewhere on the machine
|
|
152
|
+
// changes what it does.
|
|
153
|
+
fs.writeFileSync(configFile, "no-autoupdate: true\n", { mode: 0o600 });
|
|
154
|
+
let proc: Child;
|
|
155
|
+
try {
|
|
156
|
+
proc = spawn(
|
|
157
|
+
bin,
|
|
158
|
+
[
|
|
159
|
+
"tunnel",
|
|
160
|
+
"--config",
|
|
161
|
+
configFile,
|
|
162
|
+
"--no-autoupdate",
|
|
163
|
+
// Never debug: it logs every request's headers, device tokens included.
|
|
164
|
+
"--loglevel",
|
|
165
|
+
"info",
|
|
166
|
+
"--output",
|
|
167
|
+
"json",
|
|
168
|
+
"run",
|
|
169
|
+
],
|
|
170
|
+
{
|
|
171
|
+
env: {
|
|
172
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
173
|
+
HOME: home,
|
|
174
|
+
TUNNEL_TOKEN: token,
|
|
175
|
+
},
|
|
176
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
177
|
+
}
|
|
178
|
+
);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
state = "error";
|
|
181
|
+
reason = `cloudflared could not start: ${redact((err as Error).message)}`;
|
|
182
|
+
log(`connect: ${reason}`);
|
|
183
|
+
retry(delay);
|
|
184
|
+
delay = Math.min(delay * 2, delays.max);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
child = proc;
|
|
188
|
+
if (proc.pid) {
|
|
189
|
+
try {
|
|
190
|
+
fs.writeFileSync(pidFile, String(proc.pid), { mode: 0o600 });
|
|
191
|
+
} catch {}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const onLine = (line: string) => {
|
|
195
|
+
if (!line.trim()) return;
|
|
196
|
+
let entry: {
|
|
197
|
+
level?: string;
|
|
198
|
+
message?: string;
|
|
199
|
+
error?: string;
|
|
200
|
+
connIndex?: number;
|
|
201
|
+
} | null = null;
|
|
202
|
+
if (line.startsWith("{")) {
|
|
203
|
+
try {
|
|
204
|
+
entry = JSON.parse(line);
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
if (!entry) return say(`connect: cloudflared: ${redact(line)}`);
|
|
208
|
+
const message = String(entry.message ?? "");
|
|
209
|
+
const conn = String(entry.connIndex ?? "0");
|
|
210
|
+
if (message.startsWith("Registered tunnel connection")) {
|
|
211
|
+
connections.add(conn);
|
|
212
|
+
if (state !== "up") log("connect: tunnel up");
|
|
213
|
+
state = "up";
|
|
214
|
+
reason = undefined;
|
|
215
|
+
delay = delays.min;
|
|
216
|
+
} else if (
|
|
217
|
+
message.startsWith("Unregistered tunnel connection") ||
|
|
218
|
+
((entry.level === "error" || entry.level === "fatal") &&
|
|
219
|
+
entry.connIndex !== undefined)
|
|
220
|
+
) {
|
|
221
|
+
connections.delete(conn);
|
|
222
|
+
if (state === "up" && connections.size === 0) state = "connecting";
|
|
223
|
+
}
|
|
224
|
+
if (["warn", "error", "fatal"].includes(entry.level ?? "")) {
|
|
225
|
+
const text = entry.error ? `${message}: ${entry.error}` : message;
|
|
226
|
+
reason = redact(text);
|
|
227
|
+
say(`connect: cloudflared: ${reason}`);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
for (const stream of [proc.stdout, proc.stderr]) {
|
|
231
|
+
if (!stream) continue;
|
|
232
|
+
stream.on("error", () => {});
|
|
233
|
+
readline.createInterface({ input: stream }).on("line", onLine);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let ended = false;
|
|
237
|
+
const end = (why: string) => {
|
|
238
|
+
if (ended) return;
|
|
239
|
+
ended = true;
|
|
240
|
+
if (child === proc) child = null;
|
|
241
|
+
connections.clear();
|
|
242
|
+
try {
|
|
243
|
+
if (fs.readFileSync(pidFile, "utf8") === String(proc.pid))
|
|
244
|
+
fs.rmSync(pidFile, { force: true });
|
|
245
|
+
} catch {}
|
|
246
|
+
if (stopped) return;
|
|
247
|
+
state = "connecting";
|
|
248
|
+
reason = why;
|
|
249
|
+
log(`connect: ${why}; starting it again`);
|
|
250
|
+
retry(delay);
|
|
251
|
+
delay = Math.min(delay * 2, delays.max);
|
|
252
|
+
};
|
|
253
|
+
proc.on("error", (err) =>
|
|
254
|
+
end(`cloudflared failed: ${redact(err.message)}`)
|
|
255
|
+
);
|
|
256
|
+
proc.on("exit", (code) => end(`cloudflared exited (${code ?? "signal"})`));
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const killNow = () => child?.kill("SIGTERM");
|
|
260
|
+
process.once("exit", killNow);
|
|
261
|
+
tick();
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
state: () => state,
|
|
265
|
+
reason: () => reason,
|
|
266
|
+
stop() {
|
|
267
|
+
if (stopped) return;
|
|
268
|
+
stopped = true;
|
|
269
|
+
state = "stopped";
|
|
270
|
+
process.removeListener("exit", killNow);
|
|
271
|
+
if (timer) clearTimeout(timer);
|
|
272
|
+
const proc = child;
|
|
273
|
+
if (!proc) return;
|
|
274
|
+
proc.kill("SIGTERM");
|
|
275
|
+
// It drains connections for a while on SIGTERM; don't wait forever.
|
|
276
|
+
const hard = setTimeout(() => proc.kill("SIGKILL"), 5000);
|
|
277
|
+
hard.unref?.();
|
|
278
|
+
proc.on("exit", () => clearTimeout(hard));
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1521,6 +1521,78 @@ const migrations: Migration[] = [
|
|
|
1521
1521
|
);
|
|
1522
1522
|
},
|
|
1523
1523
|
},
|
|
1524
|
+
{
|
|
1525
|
+
id: 68,
|
|
1526
|
+
name: "usage_calls_and_rollups",
|
|
1527
|
+
up: (db) => {
|
|
1528
|
+
// One row per Claude response (or per one-shot run), the daily rollups
|
|
1529
|
+
// computed from them, the ratchet's baseline and the limit readings.
|
|
1530
|
+
db.transaction(() => {
|
|
1531
|
+
db.exec(`
|
|
1532
|
+
CREATE TABLE IF NOT EXISTS usage_calls (
|
|
1533
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1534
|
+
call_key TEXT NOT NULL UNIQUE,
|
|
1535
|
+
at INTEGER NOT NULL,
|
|
1536
|
+
session_id TEXT,
|
|
1537
|
+
task_id TEXT,
|
|
1538
|
+
workspace_id TEXT,
|
|
1539
|
+
project_id TEXT,
|
|
1540
|
+
session_name TEXT,
|
|
1541
|
+
role TEXT,
|
|
1542
|
+
kind TEXT NOT NULL,
|
|
1543
|
+
agent_type TEXT,
|
|
1544
|
+
model TEXT,
|
|
1545
|
+
provider TEXT,
|
|
1546
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1547
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1548
|
+
cache_write_5m_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1549
|
+
cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1550
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
1551
|
+
ctx_tokens INTEGER,
|
|
1552
|
+
source TEXT NOT NULL
|
|
1553
|
+
);
|
|
1554
|
+
CREATE INDEX IF NOT EXISTS idx_usage_calls_at ON usage_calls(at);
|
|
1555
|
+
CREATE INDEX IF NOT EXISTS idx_usage_calls_task ON usage_calls(task_id);
|
|
1556
|
+
CREATE INDEX IF NOT EXISTS idx_usage_calls_session ON usage_calls(session_id);
|
|
1557
|
+
CREATE TABLE IF NOT EXISTS rate_limit_readings (
|
|
1558
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1559
|
+
at INTEGER NOT NULL,
|
|
1560
|
+
window TEXT NOT NULL,
|
|
1561
|
+
utilization REAL NOT NULL,
|
|
1562
|
+
raw REAL,
|
|
1563
|
+
resets_at INTEGER,
|
|
1564
|
+
status TEXT,
|
|
1565
|
+
session_id TEXT,
|
|
1566
|
+
source TEXT NOT NULL
|
|
1567
|
+
);
|
|
1568
|
+
CREATE INDEX IF NOT EXISTS idx_rate_limit_readings ON rate_limit_readings(window, at);
|
|
1569
|
+
CREATE TABLE IF NOT EXISTS usage_rollups (
|
|
1570
|
+
day TEXT PRIMARY KEY,
|
|
1571
|
+
data TEXT NOT NULL,
|
|
1572
|
+
final INTEGER NOT NULL DEFAULT 0,
|
|
1573
|
+
computed_at INTEGER NOT NULL
|
|
1574
|
+
);
|
|
1575
|
+
CREATE TABLE IF NOT EXISTS usage_baseline (
|
|
1576
|
+
metric TEXT PRIMARY KEY,
|
|
1577
|
+
value REAL NOT NULL,
|
|
1578
|
+
set_at INTEGER NOT NULL,
|
|
1579
|
+
supporting TEXT
|
|
1580
|
+
);
|
|
1581
|
+
CREATE TABLE IF NOT EXISTS usage_alerts (
|
|
1582
|
+
kind TEXT PRIMARY KEY,
|
|
1583
|
+
opened_at INTEGER NOT NULL,
|
|
1584
|
+
cleared_at INTEGER,
|
|
1585
|
+
message TEXT NOT NULL
|
|
1586
|
+
);
|
|
1587
|
+
CREATE TABLE IF NOT EXISTS usage_import_offsets (
|
|
1588
|
+
path TEXT PRIMARY KEY,
|
|
1589
|
+
offset INTEGER NOT NULL,
|
|
1590
|
+
mtime INTEGER NOT NULL
|
|
1591
|
+
);
|
|
1592
|
+
`);
|
|
1593
|
+
})();
|
|
1594
|
+
},
|
|
1595
|
+
},
|
|
1524
1596
|
];
|
|
1525
1597
|
|
|
1526
1598
|
// `upTo`: stop after this id (tests that start from an older database).
|
|
@@ -25,7 +25,7 @@ export function ciWord(pr: TaskPR): string {
|
|
|
25
25
|
export const shortId = (id: string) => id.slice(0, 8);
|
|
26
26
|
|
|
27
27
|
function sessionLine(f: SessionFacts): string {
|
|
28
|
-
const head = `- ${f.name} (${f.project ?? "no project"}, ${f.quick ? "your quick change" : f.view}, id ${shortId(f.id)}): ${STATUS_WORD[f.status]}`;
|
|
28
|
+
const head = `- ${f.name} (${f.project ?? "no project"}, ${f.quick ? "your quick change" : f.view}, id ${shortId(f.id)}): ${f.compacting ? "compacting context" : STATUS_WORD[f.status]}`;
|
|
29
29
|
const parts = [
|
|
30
30
|
f.activity ? `${head}, ${untrusted(f.name, f.activity)}` : head,
|
|
31
31
|
];
|
|
@@ -9,7 +9,7 @@ import { db, type Session, type StackItemStatus } from "../db";
|
|
|
9
9
|
import { statusDetector } from "../status-detector";
|
|
10
10
|
import { hostLink } from "../hosts/remote-api";
|
|
11
11
|
import { peerStatus } from "../hosts/peer-sessions";
|
|
12
|
-
import { chatState } from "../chat/runner";
|
|
12
|
+
import { chatCompacting, chatState } from "../chat/runner";
|
|
13
13
|
import { chatActivityLine, chatStall, type Stall } from "../chat/activity";
|
|
14
14
|
import { lastUserTask } from "../chat/store";
|
|
15
15
|
import { needsYou } from "../needs-you";
|
|
@@ -35,6 +35,8 @@ export interface SessionFacts {
|
|
|
35
35
|
needsInput: boolean;
|
|
36
36
|
// A chat turn silent past the stall threshold (lib/chat/stall).
|
|
37
37
|
stalled?: Stall | null;
|
|
38
|
+
// Its agent is summarising the context: not stuck, not plain work.
|
|
39
|
+
compacting?: boolean;
|
|
38
40
|
// Last sign of life, in ms.
|
|
39
41
|
lastActive: number;
|
|
40
42
|
// Work that should end in a PR: a task, or a session on its own branch.
|
|
@@ -274,6 +276,8 @@ export async function sessionFacts(
|
|
|
274
276
|
needsInput,
|
|
275
277
|
stalled:
|
|
276
278
|
s.view === "chat" && !hostLink(s.host_id) ? chatStall(s.id) : null,
|
|
279
|
+
compacting:
|
|
280
|
+
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
277
281
|
lastActive: Math.max(
|
|
278
282
|
sqliteMs(s.updated_at),
|
|
279
283
|
statusDetector.getTimestamp(s.tmux_name) * 1000,
|
package/lib/security/auth.ts
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Who may use AgentOS once a request has passed the host/origin checks in
|
|
3
3
|
* net.ts. Loopback and the tailnet are trusted as before; anything else
|
|
4
4
|
* (Wi-Fi, a reverse proxy, Connect) needs a paired device's token.
|
|
5
|
+
*
|
|
6
|
+
* Connect's tunnel arrives from cloudflared on loopback, so its listener
|
|
7
|
+
* marks every socket it accepts: a marked socket is never loopback, never
|
|
8
|
+
* the tailnet and never let in by AGENTOS_AUTH=off, whatever its address,
|
|
9
|
+
* Host or headers say.
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import type { IncomingHttpHeaders } from "http";
|
|
@@ -23,8 +28,18 @@ export interface AuthRequest {
|
|
|
23
28
|
headers: IncomingHttpHeaders;
|
|
24
29
|
remoteAddress?: string;
|
|
25
30
|
localAddress?: string;
|
|
31
|
+
/** Came through Connect's tunnel listener: a paired device or nothing. */
|
|
32
|
+
tunnel?: boolean;
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
const TUNNEL_SOCKETS = new WeakSet<object>();
|
|
36
|
+
/** Called by the tunnel listener for every socket, before any request. */
|
|
37
|
+
export const markTunnelSocket = (socket: object) => {
|
|
38
|
+
TUNNEL_SOCKETS.add(socket);
|
|
39
|
+
};
|
|
40
|
+
export const isTunnelSocket = (socket: object | null | undefined) =>
|
|
41
|
+
!!socket && TUNNEL_SOCKETS.has(socket);
|
|
42
|
+
|
|
28
43
|
export interface AuthPolicy {
|
|
29
44
|
/** This machine's own Tailscale addresses. */
|
|
30
45
|
tailnet: string[];
|
|
@@ -146,13 +161,14 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
|
146
161
|
}
|
|
147
162
|
|
|
148
163
|
function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
149
|
-
const trusted = networkTrust(req, policy);
|
|
164
|
+
const trusted = req.tunnel ? null : networkTrust(req, policy);
|
|
150
165
|
if (trusted) return { ok: true, via: trusted };
|
|
151
166
|
const token = readToken(req.headers);
|
|
152
167
|
const device = token ? policy.lookup(token) : null;
|
|
153
168
|
if (device) return { ok: true, via: "device", deviceId: device.id };
|
|
154
|
-
// AGENTOS_AUTH=off is for a login proxy on this side of the network.
|
|
155
|
-
// Connect
|
|
156
|
-
if (policy.off && req.remoteAddress)
|
|
169
|
+
// AGENTOS_AUTH=off is for a login proxy on this side of the network.
|
|
170
|
+
// Connect's tunnel must never be let in by it.
|
|
171
|
+
if (policy.off && !req.tunnel && req.remoteAddress)
|
|
172
|
+
return { ok: true, via: "open" };
|
|
157
173
|
return { ok: false };
|
|
158
174
|
}
|
package/lib/security/gate.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
authorize,
|
|
10
10
|
cookieToken,
|
|
11
11
|
isPublicPath,
|
|
12
|
+
isTunnelSocket,
|
|
12
13
|
plainAddress,
|
|
13
14
|
DEVICE_HEADER,
|
|
14
15
|
TRUST_HEADER,
|
|
@@ -44,22 +45,24 @@ export function authPolicy(env = process.env): AuthPolicy {
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// Connect
|
|
48
|
-
// can't be steered by headers the caller chose.
|
|
49
|
-
export const sourceLabel = (remote?: string) =>
|
|
50
|
-
plainAddress(remote) || "
|
|
48
|
+
// Everything through Connect arrives from cloudflared on loopback. Give it
|
|
49
|
+
// a fixed label so rate limits can't be steered by headers the caller chose.
|
|
50
|
+
export const sourceLabel = (remote?: string, tunnel = false) =>
|
|
51
|
+
tunnel ? "connect" : plainAddress(remote) || "unknown";
|
|
51
52
|
|
|
52
53
|
function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
53
54
|
// Never trust a client-supplied verdict.
|
|
54
55
|
delete req.headers[DEVICE_HEADER];
|
|
55
56
|
delete req.headers[TRUST_HEADER];
|
|
56
|
-
|
|
57
|
+
const tunnel = isTunnelSocket(req.socket);
|
|
58
|
+
req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
|
|
57
59
|
const result = authorize(
|
|
58
60
|
{
|
|
59
61
|
url: req.url,
|
|
60
62
|
headers: req.headers,
|
|
61
63
|
remoteAddress: req.socket.remoteAddress,
|
|
62
64
|
localAddress: req.socket.localAddress,
|
|
65
|
+
tunnel,
|
|
63
66
|
},
|
|
64
67
|
policy
|
|
65
68
|
);
|
package/lib/security/reach.ts
CHANGED
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface RowStatus {
|
|
|
30
30
|
need?: SessionNeed | null;
|
|
31
31
|
unread?: boolean;
|
|
32
32
|
stalled?: boolean;
|
|
33
|
+
// A chat summarising its context (status stays "running").
|
|
34
|
+
compacting?: { since: number; before?: number };
|
|
33
35
|
asks?: number;
|
|
34
36
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
35
37
|
detail?: string | null;
|
|
@@ -41,6 +43,8 @@ export interface SidebarRow {
|
|
|
41
43
|
need: SessionNeed | null;
|
|
42
44
|
unread: boolean;
|
|
43
45
|
working: boolean;
|
|
46
|
+
// Working, but summarising its context, not on the task.
|
|
47
|
+
compacting?: boolean;
|
|
44
48
|
// Orchestration workers, nested under their conductor.
|
|
45
49
|
workers: SidebarRow[];
|
|
46
50
|
// Its task's state, when it's a task.
|
|
@@ -120,6 +124,7 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
120
124
|
need: needOf(session, status, input.tasks[session.id]),
|
|
121
125
|
unread: !!status?.unread,
|
|
122
126
|
working: status?.status === "running",
|
|
127
|
+
compacting: !!status?.compacting,
|
|
123
128
|
workers: [],
|
|
124
129
|
task: input.tasks[session.id],
|
|
125
130
|
quick: [],
|
|
@@ -256,6 +261,8 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
|
|
|
256
261
|
(a.need ?? null) === (b.need ?? null) &&
|
|
257
262
|
!!a.unread === !!b.unread &&
|
|
258
263
|
!!a.stalled === !!b.stalled &&
|
|
264
|
+
(a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
|
|
265
|
+
(a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
|
|
259
266
|
a.asks === b.asks &&
|
|
260
267
|
(a.detail ?? null) === (b.detail ?? null)
|
|
261
268
|
);
|
package/lib/status/collect.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
getSessionIdFromName,
|
|
21
21
|
} from "../providers/registry";
|
|
22
22
|
import { getDb, type Session } from "../db";
|
|
23
|
-
import { chatState } from "../chat/runner";
|
|
23
|
+
import { chatCompacting, chatState } from "../chat/runner";
|
|
24
24
|
import { chatActivityLine, chatStall } from "../chat/activity";
|
|
25
25
|
import {
|
|
26
26
|
chatNeed,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
terminalStatus,
|
|
31
31
|
} from "../needs-you";
|
|
32
32
|
import type { SessionNeed } from "../sidebar/shelves";
|
|
33
|
+
import type { Compacting } from "../chat/events";
|
|
33
34
|
import { openAskCount } from "../orchestrator/asks";
|
|
34
35
|
import { lastUserTask } from "../chat/store";
|
|
35
36
|
import { hostExecFile, isRemoteHost } from "../hosts";
|
|
@@ -62,6 +63,8 @@ export interface SessionStatusResponse {
|
|
|
62
63
|
unread?: boolean;
|
|
63
64
|
// A chat turn silent past the stall threshold (lib/chat/stall).
|
|
64
65
|
stalled?: boolean;
|
|
66
|
+
// A chat whose agent is summarising its context ("status" stays running).
|
|
67
|
+
compacting?: Compacting;
|
|
65
68
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
66
69
|
detail?: string | null;
|
|
67
70
|
progress?: number | null;
|
|
@@ -467,6 +470,9 @@ async function collect(): Promise<StatusSnapshot> {
|
|
|
467
470
|
need: chatNeed(session, state, asks),
|
|
468
471
|
unread: isUnread(session, state),
|
|
469
472
|
...(chatStall(session.id) && { stalled: true }),
|
|
473
|
+
...(chatCompacting(session.id) && {
|
|
474
|
+
compacting: chatCompacting(session.id)!,
|
|
475
|
+
}),
|
|
470
476
|
...(session.role === "orchestrator" && { asks }),
|
|
471
477
|
};
|
|
472
478
|
}
|
package/lib/tasks/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* drops it.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { taskCosts } from "../usage/calls";
|
|
7
8
|
import {
|
|
8
9
|
parseOverride,
|
|
9
10
|
resolveRole,
|
|
@@ -100,6 +101,8 @@ export interface TaskView {
|
|
|
100
101
|
plan?: { planned: boolean; reason: string | null } | null;
|
|
101
102
|
// A quick change: the orchestrator session that makes it (no agent).
|
|
102
103
|
quickOf?: string | null;
|
|
104
|
+
// What it has cost so far in weighted tokens (lib/usage/weights).
|
|
105
|
+
weighted?: number;
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
export async function createTask(opts: {
|
|
@@ -415,6 +418,11 @@ export async function listTasks(): Promise<TaskView[]> {
|
|
|
415
418
|
Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
|
|
416
419
|
remoteTaskViews(sessions.filter(isMirror)),
|
|
417
420
|
]);
|
|
421
|
+
const costs = taskCosts(local.map((t) => t.id));
|
|
422
|
+
for (const t of local) {
|
|
423
|
+
const w = costs.get(t.id);
|
|
424
|
+
if (w) t.weighted = w;
|
|
425
|
+
}
|
|
418
426
|
return [...local, ...remote].sort((a, b) =>
|
|
419
427
|
b.createdAt.localeCompare(a.createdAt)
|
|
420
428
|
);
|