@saadnvd1/agent-os 0.3.810 → 0.3.817
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 +3 -2
- package/app/api/files/upload-temp/route.test.ts +55 -3
- package/app/api/files/upload-temp/route.ts +15 -10
- package/app/api/reach/probe/route.test.ts +22 -0
- package/app/api/reach/probe/route.ts +12 -0
- package/app/api/reach/route.ts +30 -0
- package/app/api/sessions/list-size.test.ts +77 -0
- package/app/api/sessions/route.ts +21 -10
- package/app/api/tasks/list-size.test.ts +50 -0
- package/app/api/tasks/route.ts +20 -3
- package/components/Chat/FileChip.tsx +8 -4
- package/components/Chat/ViewSwitch.tsx +2 -1
- package/components/Chat/composer/paste.ts +5 -2
- package/components/Chat/composer/useAttachFiles.ts +8 -5
- package/components/Devices/NetworkSection.tsx +2 -0
- package/components/Pane/index.tsx +4 -2
- package/components/Reach/ReachRow.tsx +137 -0
- package/components/Settings/SetupSection.tsx +2 -0
- package/components/Welcome/ConnectBlock.tsx +2 -0
- package/content/tips.json +10 -0
- package/content/whats-new.json +40 -0
- package/data/devices/queries.ts +6 -1
- package/data/reach/index.ts +33 -0
- package/lib/attachments/check.ts +2 -5
- package/lib/attachments/har.ts +122 -0
- package/lib/attachments/message.ts +24 -2
- package/lib/attachments/policy.ts +104 -9
- package/lib/attachments/prepare.ts +100 -0
- package/lib/attachments/store.ts +7 -1
- package/lib/config/client-config-types.ts +2 -0
- package/lib/connect/serve.ts +22 -3
- package/lib/connect/supervise.ts +33 -9
- package/lib/db/queries.ts +31 -4
- package/lib/db/types.ts +3 -1
- package/lib/guides.ts +6 -0
- package/lib/hosts/remote-menu.ts +4 -2
- package/lib/reach/check-cli.ts +117 -0
- package/lib/reach/checkReach.ts +62 -0
- package/lib/reach/classify.ts +283 -0
- package/lib/reach/cli.ts +38 -0
- package/lib/reach/gatherFacts.ts +167 -0
- package/lib/reach/index.ts +4 -0
- package/lib/reach/probeConnect.ts +122 -0
- package/lib/reach/types.ts +96 -0
- package/lib/security/auth.ts +2 -0
- package/lib/security/demo.ts +2 -0
- package/lib/sessions/has-prompt.ts +7 -0
- package/lib/sessions/omit-null.ts +8 -0
- package/lib/tasks/contract/views.ts +12 -0
- package/lib/tasks/index.ts +9 -5
- package/lib/tasks/session.ts +15 -1
- package/lib/tips/view.ts +2 -4
- package/package.json +1 -1
- package/scripts/agent-os +3 -0
- package/scripts/install.sh +12 -1
- package/scripts/lib/commands.sh +59 -65
- package/scripts/lib/service.sh +230 -0
- package/scripts/lib/setup.sh +303 -0
- package/scripts/lib/setup.test.ts +389 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { promisify } from "util";
|
|
6
|
+
import { redactHar } from "./har";
|
|
7
|
+
import {
|
|
8
|
+
extOf,
|
|
9
|
+
isHeic,
|
|
10
|
+
type AttachmentKind,
|
|
11
|
+
type AttachmentType,
|
|
12
|
+
} from "./policy";
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
export type Prepared =
|
|
17
|
+
| {
|
|
18
|
+
ok: true;
|
|
19
|
+
data: Buffer;
|
|
20
|
+
name: string;
|
|
21
|
+
mediaType: string;
|
|
22
|
+
kind: AttachmentKind;
|
|
23
|
+
// Saved beside the file and given to the agent with its path.
|
|
24
|
+
summary?: string;
|
|
25
|
+
}
|
|
26
|
+
| { ok: false; status: 400; error: string };
|
|
27
|
+
|
|
28
|
+
export type Converter = (cmd: string, args: string[]) => Promise<unknown>;
|
|
29
|
+
|
|
30
|
+
const realConverter: Converter = (cmd, args) =>
|
|
31
|
+
run(cmd, args, { timeout: 30_000 });
|
|
32
|
+
|
|
33
|
+
// sips ships with macOS; ImageMagick and libheif cover Linux.
|
|
34
|
+
const TOOLS = (src: string, out: string): [string, string[]][] => [
|
|
35
|
+
["sips", ["-s", "format", "jpeg", src, "--out", out]],
|
|
36
|
+
["magick", [src, out]],
|
|
37
|
+
["heif-convert", [src, out]],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** A JPEG of an iPhone photo, or null when this machine has no converter. */
|
|
41
|
+
export async function heicToJpeg(
|
|
42
|
+
data: Buffer,
|
|
43
|
+
convert: Converter = realConverter
|
|
44
|
+
): Promise<Buffer | null> {
|
|
45
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-heic-"));
|
|
46
|
+
try {
|
|
47
|
+
const src = path.join(dir, "in.heic");
|
|
48
|
+
const out = path.join(dir, "out.jpg");
|
|
49
|
+
fs.writeFileSync(src, data);
|
|
50
|
+
for (const [cmd, args] of TOOLS(src, out)) {
|
|
51
|
+
try {
|
|
52
|
+
await convert(cmd, args);
|
|
53
|
+
if (fs.existsSync(out)) return fs.readFileSync(out);
|
|
54
|
+
} catch {
|
|
55
|
+
// Not installed or failed: the next tool.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
} finally {
|
|
60
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What is stored for a checked upload: secrets removed, photos converted. */
|
|
65
|
+
export async function prepareUpload(
|
|
66
|
+
type: AttachmentType,
|
|
67
|
+
name: string,
|
|
68
|
+
data: Buffer,
|
|
69
|
+
convert?: Converter
|
|
70
|
+
): Promise<Prepared> {
|
|
71
|
+
const keep: Prepared = {
|
|
72
|
+
ok: true,
|
|
73
|
+
data,
|
|
74
|
+
name,
|
|
75
|
+
mediaType: type.mediaType,
|
|
76
|
+
kind: type.kind,
|
|
77
|
+
};
|
|
78
|
+
if (type.ext === "har") {
|
|
79
|
+
const har = redactHar(data.toString("utf8"));
|
|
80
|
+
if (!har)
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 400,
|
|
84
|
+
error: `${name} doesn't look like a real HAR file.`,
|
|
85
|
+
};
|
|
86
|
+
return { ...keep, data: Buffer.from(har.json), summary: har.summary };
|
|
87
|
+
}
|
|
88
|
+
if (isHeic(name)) {
|
|
89
|
+
const jpeg = await heicToJpeg(data, convert);
|
|
90
|
+
if (!jpeg) return keep;
|
|
91
|
+
const stem = name.slice(0, name.length - extOf(name).length - 1);
|
|
92
|
+
return {
|
|
93
|
+
...keep,
|
|
94
|
+
data: jpeg,
|
|
95
|
+
name: `${stem}.jpg`,
|
|
96
|
+
mediaType: "image/jpeg",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return keep;
|
|
100
|
+
}
|
package/lib/attachments/store.ts
CHANGED
|
@@ -28,6 +28,10 @@ export function safeName(name: string, taken: Set<string>, n: number): string {
|
|
|
28
28
|
return out;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// A short description kept beside a big file (a HAR's hosts and failures),
|
|
32
|
+
// which the agent is given with the path.
|
|
33
|
+
export const summaryPath = (file: string) => `${file}.summary.txt`;
|
|
34
|
+
|
|
31
35
|
export function scopeDir(scope: string): string {
|
|
32
36
|
return path.join(attachmentsRoot(), segment(scope) || "new");
|
|
33
37
|
}
|
|
@@ -36,7 +40,8 @@ export function saveAttachment(
|
|
|
36
40
|
scope: string,
|
|
37
41
|
name: string,
|
|
38
42
|
data: Buffer,
|
|
39
|
-
mediaType: string
|
|
43
|
+
mediaType: string,
|
|
44
|
+
summary?: string
|
|
40
45
|
): ChatFile {
|
|
41
46
|
const dir = scopeDir(scope);
|
|
42
47
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -44,6 +49,7 @@ export function saveAttachment(
|
|
|
44
49
|
const file = safeName(name, taken, taken.size + 1);
|
|
45
50
|
const full = path.join(dir, file);
|
|
46
51
|
fs.writeFileSync(full, data, { mode: 0o600 });
|
|
52
|
+
if (summary) fs.writeFileSync(summaryPath(full), summary, { mode: 0o600 });
|
|
47
53
|
return { path: full, name: file, size: data.length, mediaType };
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -36,12 +36,14 @@ export interface ClientConfig {
|
|
|
36
36
|
export interface AttachmentConfig {
|
|
37
37
|
max: number;
|
|
38
38
|
maxBytes: number;
|
|
39
|
+
maxTextBytes: number;
|
|
39
40
|
maxImageBytes: number;
|
|
40
41
|
pdfInlineBytes: number;
|
|
41
42
|
types: {
|
|
42
43
|
ext: string;
|
|
43
44
|
mediaType: string;
|
|
44
45
|
kind: "image" | "pdf" | "sheet" | "doc" | "text";
|
|
46
|
+
note?: string;
|
|
45
47
|
}[];
|
|
46
48
|
}
|
|
47
49
|
|
package/lib/connect/serve.ts
CHANGED
|
@@ -46,7 +46,9 @@ import {
|
|
|
46
46
|
reapTunnel,
|
|
47
47
|
superviseTunnel,
|
|
48
48
|
type ReapDeps,
|
|
49
|
+
atStage,
|
|
49
50
|
type Spawn,
|
|
51
|
+
type Stage,
|
|
50
52
|
type TunnelState,
|
|
51
53
|
} from "./supervise";
|
|
52
54
|
|
|
@@ -61,6 +63,10 @@ export interface ConnectStatus {
|
|
|
61
63
|
enrolled?: boolean;
|
|
62
64
|
hostname?: string;
|
|
63
65
|
reason?: string;
|
|
66
|
+
/** Where the last failure came from: getting the connector, or the service. */
|
|
67
|
+
stage?: Stage;
|
|
68
|
+
/** While not up: when it went down or started trying (ms since epoch). */
|
|
69
|
+
downSince?: number;
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
const g = globalThis as unknown as { __agentosConnect?: () => ConnectStatus };
|
|
@@ -160,6 +166,8 @@ interface Running {
|
|
|
160
166
|
supervisor: ReturnType<typeof superviseTunnel> | null;
|
|
161
167
|
heartbeat: ReturnType<typeof startHeartbeat>;
|
|
162
168
|
release: () => void;
|
|
169
|
+
/** When this run started, for how long it has been trying. */
|
|
170
|
+
since: number;
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
export function startConnect(
|
|
@@ -183,11 +191,18 @@ export function startConnect(
|
|
|
183
191
|
g.__agentosConnect = () => {
|
|
184
192
|
if (!running) return base;
|
|
185
193
|
const s: TunnelState = running.supervisor?.state() ?? "connecting";
|
|
194
|
+
const state = bindError ? "error" : s === "stopped" ? "off" : s;
|
|
186
195
|
return {
|
|
187
196
|
...base,
|
|
188
197
|
hostname: hostname || base.hostname,
|
|
189
|
-
state
|
|
198
|
+
state,
|
|
190
199
|
reason: bindError ?? running.supervisor?.reason(),
|
|
200
|
+
stage: bindError ? undefined : running.supervisor?.stage(),
|
|
201
|
+
// Up has no down clock: the probe through the tunnel keeps its own.
|
|
202
|
+
downSince:
|
|
203
|
+
state === "up"
|
|
204
|
+
? undefined
|
|
205
|
+
: (running.supervisor?.downSince() ?? running.since),
|
|
191
206
|
};
|
|
192
207
|
};
|
|
193
208
|
|
|
@@ -281,9 +296,12 @@ export function startConnect(
|
|
|
281
296
|
spawn: deps.spawn,
|
|
282
297
|
delays: deps.delays,
|
|
283
298
|
prepare: async () => {
|
|
284
|
-
const bin = await ensure(dir);
|
|
299
|
+
const bin = await atStage("connector", ensure(dir));
|
|
285
300
|
// Fresh every start; the token lives only in this call and the child's env.
|
|
286
|
-
const tunnel = await
|
|
301
|
+
const tunnel = await atStage(
|
|
302
|
+
"service",
|
|
303
|
+
ask(config, machineKey, tunnelPort)
|
|
304
|
+
);
|
|
287
305
|
if (tunnel.hostname !== hostname) {
|
|
288
306
|
hostname = tunnel.hostname;
|
|
289
307
|
saveConnect({ ...config, hostname }, dir);
|
|
@@ -350,6 +368,7 @@ export function startConnect(
|
|
|
350
368
|
supervisor: null,
|
|
351
369
|
heartbeat,
|
|
352
370
|
release: lock.release,
|
|
371
|
+
since: Date.now(),
|
|
353
372
|
};
|
|
354
373
|
server.listen(tunnelPort, "127.0.0.1");
|
|
355
374
|
};
|
package/lib/connect/supervise.ts
CHANGED
|
@@ -24,6 +24,17 @@ import type { Readable, Writable } from "stream";
|
|
|
24
24
|
import { ApiError, clean } from "./control-api";
|
|
25
25
|
|
|
26
26
|
export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
|
|
27
|
+
/** Where a failure came from: getting the connector, or the Connect service. */
|
|
28
|
+
export type Stage = "connector" | "service";
|
|
29
|
+
|
|
30
|
+
/** Tags an error with the stage it came from, for the supervisor to keep. */
|
|
31
|
+
export function atStage<T>(stage: Stage, work: Promise<T>): Promise<T> {
|
|
32
|
+
return work.catch((err) => {
|
|
33
|
+
throw Object.assign(err instanceof Error ? err : new Error(String(err)), {
|
|
34
|
+
stage,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
export interface Prepared {
|
|
29
40
|
bin: string;
|
|
@@ -178,6 +189,13 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
178
189
|
|
|
179
190
|
let state: TunnelState = "connecting";
|
|
180
191
|
let reason: string | undefined;
|
|
192
|
+
let stage: Stage | undefined;
|
|
193
|
+
// When it last left "up", or started trying: how long it has been down.
|
|
194
|
+
let downSince = Date.now();
|
|
195
|
+
const set = (next: TunnelState) => {
|
|
196
|
+
if (state === "up" && next !== "up") downSince = Date.now();
|
|
197
|
+
state = next;
|
|
198
|
+
};
|
|
181
199
|
let stopped = false;
|
|
182
200
|
let child: Child | null = null;
|
|
183
201
|
let timer: NodeJS.Timeout | null = null;
|
|
@@ -203,13 +221,13 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
203
221
|
// Nothing here may reject: an unhandled rejection takes AgentOS down.
|
|
204
222
|
const tick = () =>
|
|
205
223
|
void attempt().catch(() => {
|
|
206
|
-
|
|
224
|
+
set("error");
|
|
207
225
|
retry(delays.max);
|
|
208
226
|
});
|
|
209
227
|
|
|
210
228
|
const attempt = async () => {
|
|
211
229
|
if (stopped) return;
|
|
212
|
-
|
|
230
|
+
set("connecting");
|
|
213
231
|
try {
|
|
214
232
|
const prepared = await opts.prepare();
|
|
215
233
|
if (stopped) return;
|
|
@@ -218,13 +236,15 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
218
236
|
if (stopped) return;
|
|
219
237
|
const status = err instanceof ApiError ? err.status : 0;
|
|
220
238
|
reason = clean((err as Error)?.message ?? String(err));
|
|
239
|
+
stage =
|
|
240
|
+
(err as { stage?: Stage })?.stage ?? (status ? "service" : undefined);
|
|
221
241
|
log(`connect: ${reason}`);
|
|
222
242
|
if (status === 401 || status === 403) {
|
|
223
243
|
// Revoked or disabled: it won't fix itself in seconds.
|
|
224
|
-
|
|
244
|
+
set("denied");
|
|
225
245
|
retry(delays.denied);
|
|
226
246
|
} else {
|
|
227
|
-
|
|
247
|
+
set("error");
|
|
228
248
|
retry(delay);
|
|
229
249
|
delay = Math.min(delay * 2, delays.setupMax);
|
|
230
250
|
}
|
|
@@ -265,8 +285,9 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
265
285
|
}
|
|
266
286
|
);
|
|
267
287
|
} catch (err) {
|
|
268
|
-
|
|
288
|
+
set("error");
|
|
269
289
|
reason = `cloudflared could not start: ${redact((err as Error).message)}`;
|
|
290
|
+
stage = "connector";
|
|
270
291
|
log(`connect: ${reason}`);
|
|
271
292
|
retry(delay);
|
|
272
293
|
delay = Math.min(delay * 2, delays.max);
|
|
@@ -298,8 +319,9 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
298
319
|
if (message.startsWith("Registered tunnel connection")) {
|
|
299
320
|
connections.add(conn);
|
|
300
321
|
if (state !== "up") log("connect: tunnel up");
|
|
301
|
-
|
|
322
|
+
set("up");
|
|
302
323
|
reason = undefined;
|
|
324
|
+
stage = undefined;
|
|
303
325
|
delay = delays.min;
|
|
304
326
|
} else if (
|
|
305
327
|
message.startsWith("Unregistered tunnel connection") ||
|
|
@@ -307,7 +329,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
307
329
|
entry.connIndex !== undefined)
|
|
308
330
|
) {
|
|
309
331
|
connections.delete(conn);
|
|
310
|
-
if (state === "up" && connections.size === 0)
|
|
332
|
+
if (state === "up" && connections.size === 0) set("connecting");
|
|
311
333
|
}
|
|
312
334
|
if (["warn", "error", "fatal"].includes(entry.level ?? "")) {
|
|
313
335
|
const text = entry.error ? `${message}: ${entry.error}` : message;
|
|
@@ -332,7 +354,7 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
332
354
|
fs.rmSync(pidFile, { force: true });
|
|
333
355
|
} catch {}
|
|
334
356
|
if (stopped) return;
|
|
335
|
-
|
|
357
|
+
set("connecting");
|
|
336
358
|
reason = why;
|
|
337
359
|
log(`connect: ${why}; starting it again`);
|
|
338
360
|
retry(delay);
|
|
@@ -361,10 +383,12 @@ export function superviseTunnel(opts: SuperviseOptions) {
|
|
|
361
383
|
return {
|
|
362
384
|
state: () => state,
|
|
363
385
|
reason: () => reason,
|
|
386
|
+
stage: () => stage,
|
|
387
|
+
downSince: () => (state === "up" ? undefined : downSince),
|
|
364
388
|
stop() {
|
|
365
389
|
if (stopped) return;
|
|
366
390
|
stopped = true;
|
|
367
|
-
|
|
391
|
+
set("stopped");
|
|
368
392
|
process.removeListener("exit", killNow);
|
|
369
393
|
for (const s of SIGNALS) process.removeListener(s, onSignal);
|
|
370
394
|
if (timer) clearTimeout(timer);
|
package/lib/db/queries.ts
CHANGED
|
@@ -14,11 +14,21 @@ function getStmt(db: Database.Database, sql: string): Database.Statement {
|
|
|
14
14
|
return stmt;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Every session column but the
|
|
18
|
-
// session paths read; a list gets has_brief for whether a
|
|
17
|
+
// Every session column but the large text ones, which only the single
|
|
18
|
+
// session paths read; a list gets has_brief and has_prompt for whether a
|
|
19
|
+
// brief or task prompt exists. orch_token is internal and never listed.
|
|
20
|
+
// task_prompt stays as its first 80 characters: phone app builds already
|
|
21
|
+
// installed tell a task from a chat by !!task_prompt on the list.
|
|
19
22
|
// Read from session_rows (lib/db/session-parts).
|
|
23
|
+
const LEFT_OUT_OF_LISTS = new Set([
|
|
24
|
+
"task_brief",
|
|
25
|
+
"system_prompt",
|
|
26
|
+
"task_prompt",
|
|
27
|
+
"chat_context",
|
|
28
|
+
"orch_token",
|
|
29
|
+
]);
|
|
20
30
|
const LIST_COLUMNS = SESSION_ROW_COLUMNS.filter(
|
|
21
|
-
(n) => n
|
|
31
|
+
(n) => !LEFT_OUT_OF_LISTS.has(n)
|
|
22
32
|
);
|
|
23
33
|
export function sessionListColumns(_db: Database.Database, alias = ""): string {
|
|
24
34
|
const prefix = alias ? `${alias}.` : "";
|
|
@@ -26,7 +36,7 @@ export function sessionListColumns(_db: Database.Database, alias = ""): string {
|
|
|
26
36
|
// The recipe run or feature piece a session is, as JSON, for its row's chip.
|
|
27
37
|
const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq, 'status', rr.status) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "session_rows"}.id)`;
|
|
28
38
|
const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "session_rows"}.id)`;
|
|
29
|
-
return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
|
|
39
|
+
return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, (${prefix}task_prompt IS NOT NULL AND ${prefix}task_prompt != '') AS has_prompt, substr(${prefix}task_prompt, 1, 80) AS task_prompt, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
export const queries = {
|
|
@@ -60,6 +70,23 @@ export const queries = {
|
|
|
60
70
|
ORDER BY updated_at DESC`
|
|
61
71
|
),
|
|
62
72
|
|
|
73
|
+
// The sidebar again with the Done shelf cut to its most recent finished
|
|
74
|
+
// tasks (limit), for the list a browser refetches on every write.
|
|
75
|
+
getSidebarSessionsCapped: (db: Database.Database, limit: number) =>
|
|
76
|
+
getStmt(
|
|
77
|
+
db,
|
|
78
|
+
`SELECT ${sessionListColumns(db)} FROM session_rows
|
|
79
|
+
WHERE archived_at IS NULL
|
|
80
|
+
AND (task_status IS NULL
|
|
81
|
+
OR task_status NOT IN ('dropped', 'moved'))
|
|
82
|
+
AND (task_status IS NOT 'merged' OR id IN (
|
|
83
|
+
SELECT id FROM session_rows
|
|
84
|
+
WHERE archived_at IS NULL AND task_status = 'merged'
|
|
85
|
+
ORDER BY COALESCE(NULLIF(settled_at, ''), NULLIF(merged_at, ''), updated_at) DESC
|
|
86
|
+
LIMIT ${Math.max(0, Math.floor(limit))}))
|
|
87
|
+
ORDER BY updated_at DESC`
|
|
88
|
+
),
|
|
89
|
+
|
|
63
90
|
// The tmux names AgentOS owns, for telling discovered sessions apart.
|
|
64
91
|
getManagedTmuxNames: (db: Database.Database) =>
|
|
65
92
|
getStmt(
|
package/lib/db/types.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface Session {
|
|
|
38
38
|
auto_approve: boolean;
|
|
39
39
|
host_id: string;
|
|
40
40
|
// Async tasks: a session started from a prompt that ends in a PR
|
|
41
|
-
task_prompt
|
|
41
|
+
task_prompt?: string | null;
|
|
42
42
|
// done: finished without a merge (no PR, or its PR closed).
|
|
43
43
|
// moving: handing it to another machine (moved_to); moved: handed over.
|
|
44
44
|
task_status:
|
|
@@ -53,6 +53,8 @@ export interface Session {
|
|
|
53
53
|
task_brief?: string | null;
|
|
54
54
|
// Lists carry this instead of the brief itself (0/1).
|
|
55
55
|
has_brief?: number;
|
|
56
|
+
// Likewise for the task prompt (0/1): lists leave the text out.
|
|
57
|
+
has_prompt?: number;
|
|
56
58
|
// JSON, in lists: the recipe run this session is ({id, name, seq}), and the
|
|
57
59
|
// feature piece it builds ({feature, name, position, total}).
|
|
58
60
|
recipe_run?: string | null;
|
package/lib/guides.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Where the user guides (docs/guides/*.md) are read on the web. */
|
|
2
|
+
export const GUIDES =
|
|
3
|
+
"https://github.com/saadnvd1/agent-os/blob/main/docs/guides";
|
|
4
|
+
|
|
5
|
+
export const guideUrl = (g: { file: string; anchor?: string }) =>
|
|
6
|
+
`${GUIDES}/${g.file}.md${g.anchor ? `#${g.anchor}` : ""}`;
|
package/lib/hosts/remote-menu.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* pin, select, done and delete all work: the ones that change the session
|
|
6
6
|
* run on its machine.
|
|
7
7
|
*/
|
|
8
|
+
import { hasPrompt } from "@/lib/sessions/has-prompt";
|
|
8
9
|
|
|
9
10
|
export const REMOTE_UNSUPPORTED = {
|
|
10
11
|
fork: (host: string) => `Runs on ${host}: fork it there`,
|
|
@@ -26,12 +27,13 @@ export type RemoteAction = keyof typeof REMOTE_UNSUPPORTED;
|
|
|
26
27
|
export function remoteBlocks(
|
|
27
28
|
session: {
|
|
28
29
|
host_id: string | null;
|
|
29
|
-
task_prompt
|
|
30
|
+
task_prompt?: string | null;
|
|
31
|
+
has_prompt?: number;
|
|
30
32
|
peer_mirror?: number;
|
|
31
33
|
},
|
|
32
34
|
linkedHostName: (hostId: string) => string | undefined
|
|
33
35
|
): Record<RemoteAction, string> | null {
|
|
34
|
-
if (!session.peer_mirror || !session.host_id || session
|
|
36
|
+
if (!session.peer_mirror || !session.host_id || hasPrompt(session))
|
|
35
37
|
return null;
|
|
36
38
|
const host = linkedHostName(session.host_id);
|
|
37
39
|
if (!host) return null;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-os check [--json] [--via connect|tailscale|lan]: asks the server for
|
|
3
|
+
* its reachability report. When the server can't answer, it looks at who is
|
|
4
|
+
* on the port before naming a cause: AgentOS stuck, another program, or
|
|
5
|
+
* nothing at all.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { finding } from "./classify";
|
|
9
|
+
import type { ReachFinding, ReachReport, ReachRoute } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface CheckCliDeps {
|
|
12
|
+
base: string;
|
|
13
|
+
fetch: typeof fetch;
|
|
14
|
+
out: (line: string) => void;
|
|
15
|
+
/** The program listening on the port, from lsof, or null. */
|
|
16
|
+
holder: () => Promise<string | null>;
|
|
17
|
+
/** Whether a launchd/systemd service for AgentOS is installed. */
|
|
18
|
+
serviceInstalled: () => boolean;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
reportMs?: number;
|
|
21
|
+
probeMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ROUTES = ["connect", "tailscale", "lan"] as const;
|
|
25
|
+
const PROGRAM = /^[A-Za-z0-9._-]{1,32}$/;
|
|
26
|
+
|
|
27
|
+
/** Exit code: 0 reachable, 1 a warning, 2 not reachable. */
|
|
28
|
+
export const exitCode = (r: ReachReport) =>
|
|
29
|
+
({ ok: 0, warn: 1, fail: 2 })[r.status];
|
|
30
|
+
|
|
31
|
+
async function report(
|
|
32
|
+
deps: CheckCliDeps,
|
|
33
|
+
want: ReachRoute[]
|
|
34
|
+
): Promise<ReachReport | null> {
|
|
35
|
+
const q = new URLSearchParams({ fresh: "" });
|
|
36
|
+
if (want.length) q.set("want", want.join(","));
|
|
37
|
+
try {
|
|
38
|
+
const res = await deps.fetch(`${deps.base}/api/reach?${q}`, {
|
|
39
|
+
signal: AbortSignal.timeout(deps.reportMs ?? 20_000),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok) return null;
|
|
42
|
+
return (await res.json()) as ReachReport;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The server didn't report: who, if anyone, is on its port? */
|
|
49
|
+
export async function whoIsOnThePort(
|
|
50
|
+
deps: CheckCliDeps
|
|
51
|
+
): Promise<ReachFinding> {
|
|
52
|
+
let answered = false;
|
|
53
|
+
let silent = false;
|
|
54
|
+
try {
|
|
55
|
+
const res = await deps.fetch(`${deps.base}/api/reach/probe`, {
|
|
56
|
+
signal: AbortSignal.timeout(deps.probeMs ?? 2_000),
|
|
57
|
+
});
|
|
58
|
+
answered = true;
|
|
59
|
+
const body = (await res.json().catch(() => null)) as {
|
|
60
|
+
boot?: unknown;
|
|
61
|
+
} | null;
|
|
62
|
+
if (res.ok && typeof body?.boot === "string")
|
|
63
|
+
return finding("service-stuck", "fail");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const e = err as { name?: string; cause?: { code?: string } };
|
|
66
|
+
if (!answered && e.cause?.code === "ECONNREFUSED")
|
|
67
|
+
return finding("service-down", "fail", {
|
|
68
|
+
alt: deps.serviceInstalled() ? undefined : "no-service",
|
|
69
|
+
});
|
|
70
|
+
silent = !answered && e.name === "TimeoutError";
|
|
71
|
+
}
|
|
72
|
+
const program = await deps.holder().catch(() => null);
|
|
73
|
+
// A blocked server takes the connection and never answers, probe included.
|
|
74
|
+
if (silent && program === "node") return finding("service-stuck", "fail");
|
|
75
|
+
// Something holds the port but isn't AgentOS (or doesn't speak HTTP).
|
|
76
|
+
return finding("port-taken", "fail", {
|
|
77
|
+
detail: program && PROGRAM.test(program) ? program : undefined,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const MARK = { ok: "✓", warn: "!", fail: "✗" } as const;
|
|
82
|
+
|
|
83
|
+
export function printReport(r: ReachReport, out: (line: string) => void) {
|
|
84
|
+
for (const f of r.findings) {
|
|
85
|
+
const head = f.url ? `${f.title}: ${f.url}` : f.title;
|
|
86
|
+
out(`${MARK[f.status]} ${head}`);
|
|
87
|
+
if (f.status === "ok") continue;
|
|
88
|
+
if (f.needs === "nothing") out(" Retrying on its own, nothing to do");
|
|
89
|
+
else if (f.fix) out(` → ${f.fix}`);
|
|
90
|
+
if (f.guide && f.needs === "you") out(` How to fix: ${f.guide}`);
|
|
91
|
+
}
|
|
92
|
+
if (r.status !== "ok" && r.url) out(`\nWorking link: ${r.url}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function runCheckCli(
|
|
96
|
+
args: string[],
|
|
97
|
+
deps: CheckCliDeps
|
|
98
|
+
): Promise<number> {
|
|
99
|
+
const json = args.includes("--json");
|
|
100
|
+
const i = args.findIndex((a) => a === "--via" || a.startsWith("--via="));
|
|
101
|
+
const via = i < 0 ? undefined : (args[i].split("=")[1] ?? args[i + 1]);
|
|
102
|
+
const want = i < 0 ? [] : ROUTES.filter((r) => r === via);
|
|
103
|
+
if (i >= 0 && !want.length) {
|
|
104
|
+
deps.out(`--via takes one of: ${ROUTES.join(", ")}`);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
const r =
|
|
108
|
+
(await report(deps, want)) ??
|
|
109
|
+
({
|
|
110
|
+
status: "fail",
|
|
111
|
+
findings: [await whoIsOnThePort(deps)],
|
|
112
|
+
checkedAt: new Date(deps.now?.() ?? Date.now()).toISOString(),
|
|
113
|
+
} satisfies ReachReport);
|
|
114
|
+
if (json) deps.out(JSON.stringify(r, null, 2));
|
|
115
|
+
else printReport(r, deps.out);
|
|
116
|
+
return exitCode(r);
|
|
117
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gather + classify. The facts are cached: the Connect probe goes out to the
|
|
3
|
+
* internet, so a burst of callers shares one, and a forced fresh check runs
|
|
4
|
+
* at most every 5s.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { classifyReach } from "./classify";
|
|
8
|
+
import { gatherFacts, type GatherDeps } from "./gatherFacts";
|
|
9
|
+
import type { ReachFacts, ReachReport, ReachRoute } from "./types";
|
|
10
|
+
|
|
11
|
+
const CACHE_MS = 30_000;
|
|
12
|
+
const FRESH_MIN_MS = 5_000;
|
|
13
|
+
|
|
14
|
+
let cached: { at: number; facts: ReachFacts } | null = null;
|
|
15
|
+
let inflight: Promise<ReachFacts> | null = null;
|
|
16
|
+
// The tunnel can be up while the probe through it fails: that failure gets
|
|
17
|
+
// its own clock, so it turns into "yours" after a while too.
|
|
18
|
+
let probeBadSince: number | null = null;
|
|
19
|
+
|
|
20
|
+
function ageProbe(f: ReachFacts): ReachFacts {
|
|
21
|
+
if (f.connect.state !== "up" || !f.probe || f.probe.kind === "ok") {
|
|
22
|
+
probeBadSince = null;
|
|
23
|
+
return f;
|
|
24
|
+
}
|
|
25
|
+
probeBadSince ??= f.now;
|
|
26
|
+
return {
|
|
27
|
+
...f,
|
|
28
|
+
connect: { ...f.connect, downSince: f.connect.downSince ?? probeBadSince },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function facts(fresh: boolean, deps?: GatherDeps): Promise<ReachFacts> {
|
|
33
|
+
if (inflight) return inflight;
|
|
34
|
+
const age = cached ? Date.now() - cached.at : Infinity;
|
|
35
|
+
if (cached && age < (fresh ? FRESH_MIN_MS : CACHE_MS)) return cached.facts;
|
|
36
|
+
inflight = gatherFacts(deps)
|
|
37
|
+
.then((gathered) => {
|
|
38
|
+
const f = ageProbe(gathered);
|
|
39
|
+
cached = { at: Date.now(), facts: f };
|
|
40
|
+
return f;
|
|
41
|
+
})
|
|
42
|
+
.finally(() => {
|
|
43
|
+
inflight = null;
|
|
44
|
+
});
|
|
45
|
+
return inflight;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `want`: routes the caller means to use, reported even when not set up. */
|
|
49
|
+
export async function checkReach(
|
|
50
|
+
fresh = false,
|
|
51
|
+
want: ReachRoute[] = [],
|
|
52
|
+
deps?: GatherDeps
|
|
53
|
+
): Promise<ReachReport> {
|
|
54
|
+
return classifyReach(await facts(fresh, deps), want);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** For tests. */
|
|
58
|
+
export function resetReachCache() {
|
|
59
|
+
cached = null;
|
|
60
|
+
inflight = null;
|
|
61
|
+
probeBadSince = null;
|
|
62
|
+
}
|