@monster-orange/den 0.4.21 → 0.4.22
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 +68 -0
- package/docs/distributions.md +95 -12
- package/package.json +1 -1
- package/service/install.sh +213 -63
- package/src/shared/agents/den-claude.ts +13 -1
- package/src/shared/agents/records.ts +6 -0
- package/src/shared/agents/runtime.ts +6 -0
- package/src/shared/agents/session-name.ts +21 -1
- package/src/shared/launchd-session.ts +92 -0
- package/src/shared/sessions/client.ts +24 -0
- package/src/shared/sessions/host.ts +114 -5
- package/src/shared/sessions/types.ts +5 -0
- package/src/ui/cli/agent-ops.ts +20 -2
- package/src/ui/cli/agents-command.ts +32 -1
- package/src/ui/cli/doctor-command.ts +72 -0
- package/src/ui/cli/doctor-where.ts +203 -0
- package/src/ui/cli/index.ts +1 -1
- package/src/ui/cli/service-command.ts +217 -11
- package/src/ui/cli/sessions-command.ts +46 -12
- package/src/ui/cli/web-command.ts +4 -0
- package/src/ui/server/index.ts +4 -0
- package/src/ui/server/public/agents-page.js +103 -6
- package/src/ui/server/public/agents.html +30 -0
- package/src/ui/server/public/i18n/es.js +14 -0
- package/src/ui/server/public/i18n/ja.js +14 -0
- package/src/ui/server/public/shell.js +14 -0
- package/src/ui/server/session-routes.ts +4 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D20: the launchd session a process runs in, as `launchctl managername`
|
|
3
|
+
* names it. It decides what survives a logout:
|
|
4
|
+
*
|
|
5
|
+
* Aqua, LoginWindow the desktop login session (gui/<uid>): ends when the
|
|
6
|
+
* person logs out, with every process in it.
|
|
7
|
+
* Background outside it (an ssh shell, user/<uid>): keeps running
|
|
8
|
+
* after logout, but has no login keychain and no GUI
|
|
9
|
+
* (headed apps, WebKit on macOS).
|
|
10
|
+
* System a LaunchDaemon: as Background.
|
|
11
|
+
*
|
|
12
|
+
* A child inherits its parent's session, so a session host started from
|
|
13
|
+
* the desktop dies at logout with every agent in it. Off macOS there is no
|
|
14
|
+
* such thing (null).
|
|
15
|
+
*/
|
|
16
|
+
import { spawnSync } from "child_process";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `service install --mode always` is opt-in: it is meant to keep the
|
|
20
|
+
* server, the host and every agent running across a logout, but no logout
|
|
21
|
+
* has proven it yet. Every place that offers it says so.
|
|
22
|
+
*/
|
|
23
|
+
export const ALWAYS_UNPROVEN = "not yet proven across a logout";
|
|
24
|
+
|
|
25
|
+
export type LaunchctlRunner = () => { status: number | null; stdout: string };
|
|
26
|
+
|
|
27
|
+
const defaultRunner: LaunchctlRunner = () => {
|
|
28
|
+
const r = spawnSync("launchctl", ["managername"], {
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
timeout: 2000,
|
|
31
|
+
});
|
|
32
|
+
return { status: r.status, stdout: r.stdout ?? "" };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** `launchctl managername`, or "unknown"; null off macOS. */
|
|
36
|
+
export function readLaunchdSession(
|
|
37
|
+
run: LaunchctlRunner = defaultRunner,
|
|
38
|
+
platform: NodeJS.Platform = process.platform,
|
|
39
|
+
): string | null {
|
|
40
|
+
if (platform !== "darwin") return null;
|
|
41
|
+
try {
|
|
42
|
+
const r = run();
|
|
43
|
+
const name = r.status === 0 ? r.stdout.trim() : "";
|
|
44
|
+
// Only a plain word: it is shown and stored as-is.
|
|
45
|
+
return /^[A-Za-z]{1,32}$/.test(name) ? name : "unknown";
|
|
46
|
+
} catch {
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cached: string | null | undefined;
|
|
52
|
+
|
|
53
|
+
/** This process's launchd session (it never changes, so asked once). */
|
|
54
|
+
export function launchdSession(): string | null {
|
|
55
|
+
if (cached === undefined) cached = readLaunchdSession();
|
|
56
|
+
return cached;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* D20: `web up` from a shell outside the desktop session (an ssh shell:
|
|
61
|
+
* Background) starts a server there; say what that means and the right
|
|
62
|
+
* way. null in the desktop session or off macOS.
|
|
63
|
+
*/
|
|
64
|
+
export function webUpWarning(
|
|
65
|
+
session: string | null | undefined,
|
|
66
|
+
cli: string,
|
|
67
|
+
): string | null {
|
|
68
|
+
if (session !== "Background" && session !== "System") return null;
|
|
69
|
+
return `this shell runs outside the desktop session (${session}, e.g. ssh): a server started here keeps running after logout but has no login keychain and no GUI, and its agents run here too. To run it as a service: ${cli} service install --mode always (opt-in, ${ALWAYS_UNPROVEN}) or --mode login`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a process in this session ends when the person logs out. */
|
|
73
|
+
export function endsAtLogout(session: string | null | undefined): boolean {
|
|
74
|
+
return session === "Aqua" || session === "LoginWindow";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The line doctor and the agent page show for `what` (the server, the
|
|
79
|
+
* session host, an agent) running in `session`: the fix when it ends at
|
|
80
|
+
* logout, what it lacks when it doesn't. null when there is nothing to say.
|
|
81
|
+
*/
|
|
82
|
+
export function sessionWarning(
|
|
83
|
+
session: string | null | undefined,
|
|
84
|
+
cli: string,
|
|
85
|
+
what: string,
|
|
86
|
+
): string | null {
|
|
87
|
+
if (endsAtLogout(session))
|
|
88
|
+
return `the ${what} runs in the desktop login session (${session}): it stops when you log out. To keep it running: ${cli} service install --mode always (opt-in, ${ALWAYS_UNPROVEN})`;
|
|
89
|
+
if (session === "Background" || session === "System")
|
|
90
|
+
return `the ${what} keeps running after logout (${session}); it has no login keychain and no GUI, so it uses the 1Password service-account token, headless browsers and Full Disk Access instead`;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
@@ -45,6 +45,8 @@ export class HostClient extends EventEmitter {
|
|
|
45
45
|
private readonly attachments = new Map<string, AttachHandlers>();
|
|
46
46
|
hostPid = 0;
|
|
47
47
|
hostStartedAt = "";
|
|
48
|
+
/** D20: the host's launchd session (null off macOS or an older host). */
|
|
49
|
+
hostLaunchd: string | null = null;
|
|
48
50
|
|
|
49
51
|
constructor(private readonly socket: net.Socket) {
|
|
50
52
|
super();
|
|
@@ -117,10 +119,32 @@ export class HostClient extends EventEmitter {
|
|
|
117
119
|
this.request(op, body).catch(() => undefined);
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* D18: the host's executable and whether it has Full Disk Access (null:
|
|
124
|
+
* cannot tell). null from a host older than 0.4.22.
|
|
125
|
+
*/
|
|
126
|
+
async access(): Promise<{
|
|
127
|
+
exe: string;
|
|
128
|
+
fullDiskAccess: boolean | null;
|
|
129
|
+
} | null> {
|
|
130
|
+
try {
|
|
131
|
+
const r = await this.request("access");
|
|
132
|
+
return {
|
|
133
|
+
exe: String(r.exe ?? ""),
|
|
134
|
+
fullDiskAccess:
|
|
135
|
+
typeof r.fullDiskAccess === "boolean" ? r.fullDiskAccess : null,
|
|
136
|
+
};
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e instanceof HostError && e.code === "bad-request") return null;
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
120
143
|
async hello(): Promise<void> {
|
|
121
144
|
const r = await this.request("hello");
|
|
122
145
|
this.hostPid = Number(r.pid);
|
|
123
146
|
this.hostStartedAt = String(r.startedAt ?? "");
|
|
147
|
+
this.hostLaunchd = typeof r.launchd === "string" ? r.launchd : null;
|
|
124
148
|
}
|
|
125
149
|
|
|
126
150
|
async start(
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type Request,
|
|
30
30
|
} from "./protocol.js";
|
|
31
31
|
import type { PtyLoad, PtyProcess } from "./pty.js";
|
|
32
|
+
import { launchdSession } from "../launchd-session.js";
|
|
32
33
|
import {
|
|
33
34
|
SESSION_POOLS,
|
|
34
35
|
SESSION_SIGNALS,
|
|
@@ -54,6 +55,14 @@ export interface SessionHostOptions {
|
|
|
54
55
|
log?: (line: string) => void;
|
|
55
56
|
/** Called after a `shutdown` request has closed the host. */
|
|
56
57
|
onShutdown?: () => void;
|
|
58
|
+
/** D20: the launchd session it runs in (default: asked; null off macOS). */
|
|
59
|
+
launchdSession?: string | null;
|
|
60
|
+
/**
|
|
61
|
+
* D18: whether this process has Full Disk Access (default: it can list
|
|
62
|
+
* macOS's TCC folder, which only such a process can; null off macOS).
|
|
63
|
+
* What it starts inherits it, so the agents have it too.
|
|
64
|
+
*/
|
|
65
|
+
fullDiskAccess?: () => boolean | null;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
interface Session {
|
|
@@ -169,6 +178,27 @@ function printableActor(v: unknown): string {
|
|
|
169
178
|
|
|
170
179
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
171
180
|
|
|
181
|
+
/**
|
|
182
|
+
* D18: Full Disk Access, as macOS grants it to this executable: only such
|
|
183
|
+
* a process may list ~/Library/Application Support/com.apple.TCC. null off
|
|
184
|
+
* macOS, or when the folder is not there to ask.
|
|
185
|
+
*/
|
|
186
|
+
export function hasFullDiskAccess(
|
|
187
|
+
home: string = process.env.HOME ?? "",
|
|
188
|
+
platform: NodeJS.Platform = process.platform,
|
|
189
|
+
): boolean | null {
|
|
190
|
+
if (platform !== "darwin" || !home) return null;
|
|
191
|
+
try {
|
|
192
|
+
fs.readdirSync(
|
|
193
|
+
path.join(home, "Library", "Application Support", "com.apple.TCC"),
|
|
194
|
+
);
|
|
195
|
+
return true;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
198
|
+
return code === "EPERM" || code === "EACCES" ? false : null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
172
202
|
export class SessionHost {
|
|
173
203
|
readonly paths: SessionPaths;
|
|
174
204
|
private readonly sessions = new Map<string, Session>();
|
|
@@ -176,9 +206,17 @@ export class SessionHost {
|
|
|
176
206
|
private server: net.Server | null = null;
|
|
177
207
|
private readonly startedAt = new Date().toISOString();
|
|
178
208
|
private closed = false;
|
|
209
|
+
/** D20: where it runs; stamped on every session it starts. */
|
|
210
|
+
private readonly launchd: string | null;
|
|
179
211
|
|
|
180
212
|
constructor(private readonly opts: SessionHostOptions) {
|
|
181
213
|
this.paths = sessionPaths(opts.stateDir);
|
|
214
|
+
const where =
|
|
215
|
+
opts.launchdSession !== undefined
|
|
216
|
+
? opts.launchdSession
|
|
217
|
+
: launchdSession();
|
|
218
|
+
// "unknown" (launchctl said nothing) is no stamp at all.
|
|
219
|
+
this.launchd = where === "unknown" ? null : where;
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
private log(line: string): void {
|
|
@@ -189,6 +227,34 @@ export class SessionHost {
|
|
|
189
227
|
return this.opts.scrollbackBytes ?? DEFAULT_SCROLLBACK_BYTES;
|
|
190
228
|
}
|
|
191
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Calls `onGone` once its state dir no longer exists (checked every
|
|
232
|
+
* `everyMs`): nothing it owns can be kept any more, so the host should
|
|
233
|
+
* end. A test that deletes its temp state dir no longer leaves a host
|
|
234
|
+
* running; a real install never deletes its state dir under a running
|
|
235
|
+
* host. Returns a stop function.
|
|
236
|
+
*/
|
|
237
|
+
watchStateDir(everyMs: number, onGone: () => void): () => void {
|
|
238
|
+
const t = setInterval(() => {
|
|
239
|
+
if (fs.existsSync(this.opts.stateDir)) return;
|
|
240
|
+
clearInterval(t);
|
|
241
|
+
onGone();
|
|
242
|
+
}, everyMs);
|
|
243
|
+
t.unref();
|
|
244
|
+
return () => clearInterval(t);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* D18: serve once no other host answers on the socket, checking every
|
|
249
|
+
* `everyMs`; resolves once it listens.
|
|
250
|
+
*/
|
|
251
|
+
async listenWhenFree(everyMs: number): Promise<"listening"> {
|
|
252
|
+
for (;;) {
|
|
253
|
+
if ((await this.listen()) === "listening") return "listening";
|
|
254
|
+
await sleep(everyMs);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
192
258
|
/** Serve on the socket, or stand down when a host already answers there. */
|
|
193
259
|
async listen(): Promise<"listening" | "already-running"> {
|
|
194
260
|
const { socket } = this.paths;
|
|
@@ -293,6 +359,7 @@ export class SessionHost {
|
|
|
293
359
|
exited?: boolean;
|
|
294
360
|
exitCode?: number;
|
|
295
361
|
prompted?: boolean;
|
|
362
|
+
launchd?: unknown;
|
|
296
363
|
};
|
|
297
364
|
if (typeof rec.id !== "string" || typeof rec.name !== "string")
|
|
298
365
|
continue;
|
|
@@ -308,6 +375,10 @@ export class SessionHost {
|
|
|
308
375
|
? { exitCode: rec.exitCode }
|
|
309
376
|
: {}),
|
|
310
377
|
...(rec.prompted === true ? { prompted: true } : {}),
|
|
378
|
+
...(typeof rec.launchd === "string" &&
|
|
379
|
+
/^[A-Za-z]{1,32}$/.test(rec.launchd)
|
|
380
|
+
? { launchd: rec.launchd }
|
|
381
|
+
: {}),
|
|
311
382
|
},
|
|
312
383
|
cwd: rec.cwd,
|
|
313
384
|
pty: null,
|
|
@@ -338,6 +409,7 @@ export class SessionHost {
|
|
|
338
409
|
...(s.info.exited ? { exited: true } : {}),
|
|
339
410
|
...(s.info.exitCode !== undefined ? { exitCode: s.info.exitCode } : {}),
|
|
340
411
|
...(s.info.prompted ? { prompted: true } : {}),
|
|
412
|
+
...(s.info.launchd ? { launchd: s.info.launchd } : {}),
|
|
341
413
|
};
|
|
342
414
|
try {
|
|
343
415
|
fs.writeFileSync(
|
|
@@ -419,6 +491,7 @@ export class SessionHost {
|
|
|
419
491
|
v: PROTOCOL_VERSION,
|
|
420
492
|
pid: process.pid,
|
|
421
493
|
startedAt: this.startedAt,
|
|
494
|
+
launchd: this.launchd,
|
|
422
495
|
};
|
|
423
496
|
case "list":
|
|
424
497
|
return {
|
|
@@ -498,6 +571,12 @@ export class SessionHost {
|
|
|
498
571
|
this.forget(s);
|
|
499
572
|
return {};
|
|
500
573
|
}
|
|
574
|
+
case "access":
|
|
575
|
+
// D18: what this host (and every agent in it) may reach.
|
|
576
|
+
return {
|
|
577
|
+
exe: process.execPath,
|
|
578
|
+
fullDiskAccess: (this.opts.fullDiskAccess ?? hasFullDiskAccess)(),
|
|
579
|
+
};
|
|
501
580
|
default:
|
|
502
581
|
throw new Refusal("bad-request", `unknown op "${String(req.op)}"`);
|
|
503
582
|
}
|
|
@@ -607,6 +686,7 @@ export class SessionHost {
|
|
|
607
686
|
name,
|
|
608
687
|
pid: pty.pid,
|
|
609
688
|
startedAt: new Date().toISOString(),
|
|
689
|
+
...(this.launchd ? { launchd: this.launchd } : {}),
|
|
610
690
|
},
|
|
611
691
|
cwd,
|
|
612
692
|
pty,
|
|
@@ -712,7 +792,19 @@ export class SessionHost {
|
|
|
712
792
|
* already running; 1 when it cannot listen.
|
|
713
793
|
*/
|
|
714
794
|
export async function runSessionHost(
|
|
715
|
-
opts: Omit<SessionHostOptions, "onShutdown"
|
|
795
|
+
opts: Omit<SessionHostOptions, "onShutdown"> & {
|
|
796
|
+
/**
|
|
797
|
+
* D18: the launchd job's host. While another host answers on the
|
|
798
|
+
* socket (one started before the job, from a terminal or the web
|
|
799
|
+
* server), wait for it to end, then serve, instead of exiting at once
|
|
800
|
+
* (launchd would restart it every few seconds). Checks every
|
|
801
|
+
* standByMs (default 5 s).
|
|
802
|
+
*/
|
|
803
|
+
standBy?: boolean;
|
|
804
|
+
standByMs?: number;
|
|
805
|
+
/** How often it checks its state dir still exists (default 10 s). */
|
|
806
|
+
stateDirCheckMs?: number;
|
|
807
|
+
},
|
|
716
808
|
): Promise<number> {
|
|
717
809
|
const log =
|
|
718
810
|
opts.log ??
|
|
@@ -729,15 +821,32 @@ export async function runSessionHost(
|
|
|
729
821
|
log(`session host could not start: ${(e as Error).message}`);
|
|
730
822
|
return 1;
|
|
731
823
|
}
|
|
732
|
-
|
|
824
|
+
const stop = () => void host.close().then(() => process.exit(0));
|
|
825
|
+
if (state === "already-running" && opts.standBy) {
|
|
826
|
+
process.on("SIGTERM", stop);
|
|
827
|
+
process.on("SIGINT", stop);
|
|
828
|
+
log(
|
|
829
|
+
`a session host is already running on ${host.paths.socket}: standing by until it ends (its sessions keep running there)`,
|
|
830
|
+
);
|
|
831
|
+
try {
|
|
832
|
+
state = await host.listenWhenFree(opts.standByMs ?? 5000);
|
|
833
|
+
} catch (e) {
|
|
834
|
+
log(`session host could not start: ${(e as Error).message}`);
|
|
835
|
+
return 1;
|
|
836
|
+
}
|
|
837
|
+
} else if (state === "already-running") {
|
|
733
838
|
log(`a session host is already running on ${host.paths.socket}`);
|
|
734
839
|
return 0;
|
|
840
|
+
} else {
|
|
841
|
+
process.on("SIGTERM", stop);
|
|
842
|
+
process.on("SIGINT", stop);
|
|
735
843
|
}
|
|
736
|
-
const stop = () => void host.close().then(() => process.exit(0));
|
|
737
|
-
process.on("SIGTERM", stop);
|
|
738
|
-
process.on("SIGINT", stop);
|
|
739
844
|
// No terminal to hang up on: it was started detached.
|
|
740
845
|
process.on("SIGHUP", () => undefined);
|
|
741
846
|
log(`session host ${process.pid} listening on ${host.paths.socket}`);
|
|
847
|
+
host.watchStateDir(opts.stateDirCheckMs ?? 10_000, () => {
|
|
848
|
+
log(`its state dir ${opts.stateDir} is gone: stopping`);
|
|
849
|
+
stop();
|
|
850
|
+
});
|
|
742
851
|
return 0;
|
|
743
852
|
}
|
|
@@ -30,6 +30,11 @@ export interface SessionInfo {
|
|
|
30
30
|
* starts afresh instead. Never what was typed.
|
|
31
31
|
*/
|
|
32
32
|
prompted?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* D20: the launchd session its host ran in (`launchctl managername`):
|
|
35
|
+
* Aqua ends at logout, Background survives it. Absent off macOS.
|
|
36
|
+
*/
|
|
37
|
+
launchd?: string;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/**
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -71,7 +71,10 @@ import {
|
|
|
71
71
|
type CatalogEntry,
|
|
72
72
|
} from "../../shared/agents/catalog.js";
|
|
73
73
|
import { checkRuntimeBin } from "../../shared/agents/catalog-runtime.js";
|
|
74
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
agentSessionName,
|
|
76
|
+
peerSessionName,
|
|
77
|
+
} from "../../shared/agents/session-name.js";
|
|
75
78
|
import { cliEntrypoint } from "../../shared/engine.js";
|
|
76
79
|
import { getDistRoot } from "../../shared/paths.js";
|
|
77
80
|
import { envKey } from "../../shared/product.js";
|
|
@@ -1637,11 +1640,17 @@ export async function startAgent(
|
|
|
1637
1640
|
);
|
|
1638
1641
|
ptyOrRefuse(d);
|
|
1639
1642
|
const allow = allowedFor(d, persona, ch.extras);
|
|
1643
|
+
// D24: the name peers see it by, from the name it will have (below).
|
|
1644
|
+
const peerName = peerSessionName(d.cli, ws, {
|
|
1645
|
+
id,
|
|
1646
|
+
displayName: given ?? prev?.displayName ?? persona.displayName,
|
|
1647
|
+
});
|
|
1640
1648
|
const input = {
|
|
1641
1649
|
...launchInput(d, cfg, persona, ws, id, model, det.path, undefined, {
|
|
1642
1650
|
...(ch.permission ? { permission: ch.permission } : {}),
|
|
1643
1651
|
...(rt.permissions ? { allow } : {}),
|
|
1644
1652
|
}),
|
|
1653
|
+
sessionLabel: peerName,
|
|
1645
1654
|
...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
|
|
1646
1655
|
// 0.4.18: Start agent here: in a folder of the workspace's.
|
|
1647
1656
|
...(req.folder !== undefined && !more.bringIn
|
|
@@ -1699,6 +1708,7 @@ export async function startAgent(
|
|
|
1699
1708
|
workspace: ws,
|
|
1700
1709
|
cwd: input.worktree,
|
|
1701
1710
|
sessionName: sessionName(d.dist, ws, id),
|
|
1711
|
+
peerName,
|
|
1702
1712
|
// AG-4b: a name given to the agent is the agent's, not its
|
|
1703
1713
|
// conversation's: Start fresh and Relaunch keep it. AG-10.8:
|
|
1704
1714
|
// else its persona's name ("Rita").
|
|
@@ -2175,8 +2185,11 @@ export async function resumeAgent(
|
|
|
2175
2185
|
},
|
|
2176
2186
|
);
|
|
2177
2187
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
2188
|
+
// D24 (and D25: a rename since) the name peers see it by.
|
|
2189
|
+
const peerName = peerSessionName(d.cli, rec.workspace, rec);
|
|
2178
2190
|
const at = {
|
|
2179
2191
|
...input,
|
|
2192
|
+
sessionLabel: peerName,
|
|
2180
2193
|
worktree: rec.cwd,
|
|
2181
2194
|
...(rec.resumeToken ? { resumeToken: rec.resumeToken } : {}),
|
|
2182
2195
|
};
|
|
@@ -2211,7 +2224,12 @@ export async function resumeAgent(
|
|
|
2211
2224
|
d,
|
|
2212
2225
|
c,
|
|
2213
2226
|
{
|
|
2214
|
-
rec: {
|
|
2227
|
+
rec: {
|
|
2228
|
+
...base,
|
|
2229
|
+
cwd: rec.cwd,
|
|
2230
|
+
peerName,
|
|
2231
|
+
...(token ? { launched: true } : {}),
|
|
2232
|
+
},
|
|
2215
2233
|
argv: out.argv,
|
|
2216
2234
|
env: out.env,
|
|
2217
2235
|
passthrough: rt.envPassthrough,
|
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
relaunchAgent,
|
|
88
88
|
removeAgent,
|
|
89
89
|
removeStopped,
|
|
90
|
+
renameAgent,
|
|
90
91
|
resolveAgentsDeps,
|
|
91
92
|
resumeAgent,
|
|
92
93
|
resumeInterrupted,
|
|
@@ -99,6 +100,7 @@ import {
|
|
|
99
100
|
type RuntimeChoice,
|
|
100
101
|
type Started,
|
|
101
102
|
} from "./agent-ops.js";
|
|
103
|
+
import { peerSessionName } from "../../shared/agents/session-name.js";
|
|
102
104
|
import {
|
|
103
105
|
columns,
|
|
104
106
|
guarded,
|
|
@@ -119,7 +121,7 @@ export {
|
|
|
119
121
|
|
|
120
122
|
export const AGENTS_HELP = {
|
|
121
123
|
usage:
|
|
122
|
-
"agents <list|start|stop|resume|relaunch|import|persona|rm|personas|doctor|handback-check|handoff-check> ...",
|
|
124
|
+
"agents <list|start|stop|resume|relaunch|import|persona|rename|rm|personas|doctor|handback-check|handoff-check> ...",
|
|
123
125
|
lines: [
|
|
124
126
|
"Agents: a persona and a workspace, run by a runtime in a session that",
|
|
125
127
|
"outlives the web server (see `{cli} sessions`).",
|
|
@@ -156,6 +158,11 @@ export const AGENTS_HELP = {
|
|
|
156
158
|
" persona's role, permissions and commands; id, name, folder and model stay.",
|
|
157
159
|
" Mid-turn it asks: --after-turn waits for the turn to end, --now stops it.",
|
|
158
160
|
" A person's only (refused in an agent's session)",
|
|
161
|
+
"{cli} agents rename <id> <name> [-w <ws>]",
|
|
162
|
+
"{cli} agents rename <id> --clear [-w <ws>]",
|
|
163
|
+
" name an agent (its tab, row and pane header); the id never changes.",
|
|
164
|
+
" Other sessions see it as {cli}-<ws>:<name> from its next start or resume.",
|
|
165
|
+
" --clear: back to its id",
|
|
159
166
|
"{cli} agents rm <id> [-w <ws>]",
|
|
160
167
|
"{cli} agents rm --stopped [-w <ws>]",
|
|
161
168
|
" forget a stopped, interrupted or exited agent (never a running one);",
|
|
@@ -513,6 +520,28 @@ async function resumeAll(args: string[], d: AgentsResolved): Promise<number> {
|
|
|
513
520
|
}
|
|
514
521
|
|
|
515
522
|
/** `agents rm <id> [-w <ws>]`, `agents rm --stopped [-w <ws>]` (AG-14.1b). */
|
|
523
|
+
/** D25: `agents rename <id> <name>|--clear [-w <ws>]`. */
|
|
524
|
+
function rename(args: string[], d: AgentsResolved): number {
|
|
525
|
+
const where = "agents rename";
|
|
526
|
+
const p = parseArgs(where, args, { "--workspace": true, "--clear": false });
|
|
527
|
+
const [id, ...words] = p.positional;
|
|
528
|
+
if (!id) throw new UsageError(`${where} needs <id> and a name, or --clear`);
|
|
529
|
+
const name = words.join(" ").trim();
|
|
530
|
+
if (p.flags["--clear"] && name)
|
|
531
|
+
throw new UsageError(`${where}: give a name or --clear, not both`);
|
|
532
|
+
if (!p.flags["--clear"] && !name)
|
|
533
|
+
throw new UsageError(`${where} needs a name (or --clear)`);
|
|
534
|
+
const ws =
|
|
535
|
+
workspaceFlag(where, p) ?? readRecord(d.stateDir(), id)?.workspace ?? "";
|
|
536
|
+
const rec = renameAgent(d, id, ws, p.flags["--clear"] ? null : name);
|
|
537
|
+
d.out(
|
|
538
|
+
rec.displayName
|
|
539
|
+
? `renamed agent ${id} to "${rec.displayName}"; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`
|
|
540
|
+
: `agent ${id} has no name now; others see it as ${peerSessionName(d.cli, rec.workspace, rec)} from its next start or resume`,
|
|
541
|
+
);
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
|
|
516
545
|
async function rm(args: string[], d: AgentsResolved): Promise<number> {
|
|
517
546
|
const where = "agents rm";
|
|
518
547
|
const p = parseArgs(where, args, { "--workspace": true, "--stopped": false });
|
|
@@ -767,6 +796,8 @@ export async function cmdAgents(
|
|
|
767
796
|
case "persona":
|
|
768
797
|
await loadAgentWorkspaces(deps);
|
|
769
798
|
return await persona(rest, d);
|
|
799
|
+
case "rename":
|
|
800
|
+
return rename(rest, d);
|
|
770
801
|
case "rm":
|
|
771
802
|
await loadAgentWorkspaces(deps);
|
|
772
803
|
return await rm(rest, d);
|
|
@@ -67,6 +67,13 @@ import {
|
|
|
67
67
|
} from "./doctor-onboard.js";
|
|
68
68
|
import { printJson } from "./json-out.js";
|
|
69
69
|
import { ownerCheck, ownerLine } from "./doctor-owner.js";
|
|
70
|
+
import {
|
|
71
|
+
formatWhere,
|
|
72
|
+
serviceJobState,
|
|
73
|
+
whereRows,
|
|
74
|
+
type WhereDeps,
|
|
75
|
+
type WhereRow,
|
|
76
|
+
} from "./doctor-where.js";
|
|
70
77
|
|
|
71
78
|
export interface DoctorRow {
|
|
72
79
|
kind: string;
|
|
@@ -387,6 +394,62 @@ export async function doctorJson(
|
|
|
387
394
|
return { ok: !checks.some((c) => c.status === "fail"), checks };
|
|
388
395
|
}
|
|
389
396
|
|
|
397
|
+
/** D18/D20: the real probes for doctor's "where it runs" rows. */
|
|
398
|
+
export async function defaultWhereDeps(): Promise<WhereDeps> {
|
|
399
|
+
const os = await import("os");
|
|
400
|
+
const { readDenConfig: cfg, readWebServiceConfig } =
|
|
401
|
+
await import("../../shared/den-config.js");
|
|
402
|
+
const { readWebPort } = await import("../../shared/edge/config.js");
|
|
403
|
+
const { getDistRoot } = await import("../../shared/paths.js");
|
|
404
|
+
const { connectHost } = await import("../../shared/sessions/client.js");
|
|
405
|
+
const { sessionPaths } = await import("../../shared/sessions/paths.js");
|
|
406
|
+
return {
|
|
407
|
+
cli: product().cli,
|
|
408
|
+
platform: process.platform,
|
|
409
|
+
server: async () => {
|
|
410
|
+
const port = readWebPort(getDistRoot());
|
|
411
|
+
const res = await fetch(`http://localhost:${port}/api/server-info`, {
|
|
412
|
+
signal: AbortSignal.timeout(1500),
|
|
413
|
+
});
|
|
414
|
+
if (!res.ok) return null;
|
|
415
|
+
const body = (await res.json()) as { launchd?: string | null };
|
|
416
|
+
return { launchd: body.launchd ?? null };
|
|
417
|
+
},
|
|
418
|
+
host: async () => {
|
|
419
|
+
const c = await connectHost(sessionPaths(getStateDir()).socket);
|
|
420
|
+
try {
|
|
421
|
+
return {
|
|
422
|
+
launchd: c.hostLaunchd,
|
|
423
|
+
access: await c.access().catch(() => null),
|
|
424
|
+
};
|
|
425
|
+
} finally {
|
|
426
|
+
c.close();
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
service: () => {
|
|
430
|
+
try {
|
|
431
|
+
const { label } = readWebServiceConfig(
|
|
432
|
+
cfg(),
|
|
433
|
+
getDistRoot(),
|
|
434
|
+
os.homedir(),
|
|
435
|
+
);
|
|
436
|
+
return serviceJobState(label, os.homedir(), process.getuid?.() ?? 0);
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** A "where it runs" row as a `doctor --json` check. */
|
|
445
|
+
export function whereCheck(r: WhereRow): OnboardCheck {
|
|
446
|
+
return {
|
|
447
|
+
id: `where:${r.what.replace(" ", "-")}`,
|
|
448
|
+
status: r.status,
|
|
449
|
+
detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
390
453
|
export async function cmdDoctor(args: string[]): Promise<number> {
|
|
391
454
|
const probe = args.includes("--probe");
|
|
392
455
|
const json = args.includes("--json");
|
|
@@ -415,6 +478,9 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
415
478
|
const r = await doctorJson(
|
|
416
479
|
defaultOnboardDeps((url) => probeWebServer(url)),
|
|
417
480
|
);
|
|
481
|
+
r.checks.push(
|
|
482
|
+
...(await whereRows(await defaultWhereDeps())).map(whereCheck),
|
|
483
|
+
);
|
|
418
484
|
printJson(r);
|
|
419
485
|
return r.ok ? 0 : 1;
|
|
420
486
|
}
|
|
@@ -427,6 +493,12 @@ export async function cmdDoctor(args: string[]): Promise<number> {
|
|
|
427
493
|
console.log("");
|
|
428
494
|
console.log(ownerLine(owner));
|
|
429
495
|
const ownerProblem = owner.status === "fail";
|
|
496
|
+
// D18/D20: where the server and the session host run (logout).
|
|
497
|
+
for (const l of formatWhere(
|
|
498
|
+
await whereRows(await defaultWhereDeps()),
|
|
499
|
+
readBrand().name,
|
|
500
|
+
))
|
|
501
|
+
console.log(l);
|
|
430
502
|
for (const l of pollingLines(eventsFile(), readBrand().name)) console.log(l);
|
|
431
503
|
const cli = product().cli;
|
|
432
504
|
if (!probe) {
|