@monster-orange/den 0.4.7 → 0.4.9
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 +174 -0
- package/README.md +7 -7
- package/docs/distributions.md +243 -1
- package/package.json +1 -1
- package/schemas/den.plugin.v1.json +3 -2
- package/src/shared/agents/den-claude.ts +57 -3
- package/src/shared/agents/guardrails/hooks.json +23 -0
- package/src/shared/agents/guardrails/instructions.md +23 -0
- package/src/shared/agents/guardrails/rotation-hook.ts +127 -0
- package/src/shared/agents/guardrails/stop-hook.ts +48 -2
- package/src/shared/agents/guardrails.ts +38 -7
- package/src/shared/agents/handoff.ts +118 -0
- package/src/shared/agents/personas.ts +11 -1
- package/src/shared/agents/records.ts +122 -1
- package/src/shared/agents/rotation.ts +362 -0
- package/src/shared/agents/runtime.ts +27 -0
- package/src/shared/den-config.ts +72 -1
- package/src/shared/git/gh-pr-state.ts +32 -0
- package/src/shared/plugins/manifest.ts +3 -0
- package/src/shared/usage/paths.ts +16 -0
- package/src/shared/usage/report.ts +377 -0
- package/src/shared/usage/sources.ts +127 -0
- package/src/shared/usage/transcript.ts +319 -0
- package/src/ui/cli/agent-ops.ts +678 -51
- package/src/ui/cli/agents-command.ts +192 -22
- package/src/ui/cli/index.ts +13 -1
- package/src/ui/cli/usage-command.ts +323 -0
- package/src/ui/mcp/api.ts +10 -1
- package/src/ui/mcp/server.ts +34 -0
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-resume-grant.ts +53 -0
- package/src/ui/server/agent-routes.ts +247 -9
- package/src/ui/server/audit.ts +68 -1
- package/src/ui/server/index.ts +16 -5
- package/src/ui/server/public/agents-page.js +400 -13
- package/src/ui/server/public/agents-section.js +17 -3
- package/src/ui/server/public/agents.html +44 -0
- package/src/ui/server/public/i18n/es.js +40 -0
- package/src/ui/server/public/i18n/ja.js +36 -0
- package/src/ui/server/public/shell.js +79 -0
- package/src/ui/server/request-guard.ts +21 -1
- package/src/ui/server/route-classes.ts +29 -0
- package/src/ui/server/session-routes.ts +60 -5
- package/src/ui/server/sudo-store.ts +36 -7
|
@@ -387,7 +387,43 @@
|
|
|
387
387
|
"ag.cancel": "キャンセル",
|
|
388
388
|
"ag.started": "エージェント {id} を開始しました",
|
|
389
389
|
"ag.stopped": "エージェント {id} を停止しました",
|
|
390
|
+
"ag.stillStopping": "{id} はまだ停止中です",
|
|
391
|
+
"ag.resumedFresh": "{id}: まだ再開するものがないため、新しく開始しました",
|
|
392
|
+
"ag.inactive": "ワークスペース {ws} はアクティブではありません",
|
|
393
|
+
"ag.switchWs": "{ws} に切り替え",
|
|
394
|
+
"ag.unknownWs": "(その名前のワークスペースはありません)",
|
|
390
395
|
"ag.resumed": "エージェント {id} を再開しました",
|
|
396
|
+
"ag.state.interrupted": "中断",
|
|
397
|
+
"ag.why.stopped": "{time} に停止しました",
|
|
398
|
+
"ag.why.interrupted": "中断: {name} が再起動しました",
|
|
399
|
+
"ag.why.exited": "終了しました",
|
|
400
|
+
"ag.why.exitedCode": "終了しました(コード {code})",
|
|
401
|
+
"ag.fresh": "新しく始める",
|
|
402
|
+
"ag.freshName": "{id} を新しく始める",
|
|
403
|
+
"ag.startedFresh": "{id} を新しく始めました",
|
|
404
|
+
"ag.rot.ready": "再起動の準備ができました({why})",
|
|
405
|
+
"ag.rot.due": "引き継ぎが必要です({why})",
|
|
406
|
+
"ag.rot.compactions": "圧縮 {n} 回",
|
|
407
|
+
"ag.rot.merged": "PR がマージされました",
|
|
408
|
+
"ag.rot.noHandoff":
|
|
409
|
+
"セッションは交代しました({why})が、handoff.md がありません。再開して書かせるか、新しく始めてください。",
|
|
410
|
+
"ag.rot.badHandoff":
|
|
411
|
+
"セッションは交代しました({why})が、handoff.md が不完全です。再開して直すか、新しく始めてください。",
|
|
412
|
+
"ag.relaunch": "引き継ぎから再起動",
|
|
413
|
+
"ag.relaunchName": "{id} を引き継ぎから再起動",
|
|
414
|
+
"ag.relaunched": "{id} を引き継ぎから再起動しました",
|
|
415
|
+
"ag.remove": "削除",
|
|
416
|
+
"ag.removeName": "{id} を削除",
|
|
417
|
+
"ag.removed": "{id} を削除しました",
|
|
418
|
+
"ag.removedMany": "停止中のエージェント {n} 件を削除しました",
|
|
419
|
+
"ag.undo": "元に戻す",
|
|
420
|
+
"ag.restored": "一覧に戻しました: {ids}",
|
|
421
|
+
"ag.clear": "停止中を削除",
|
|
422
|
+
"ag.noResume": "{runtime} は会話を再開できません",
|
|
423
|
+
"ag.interrupted": "{name} の再起動でエージェント {n} 件が中断されました",
|
|
424
|
+
"ag.resumeAll": "すべて再開",
|
|
425
|
+
"ag.review": "確認",
|
|
426
|
+
"ag.resumedAll": "エージェント {n} 件を再開しました",
|
|
391
427
|
// The Agents tab (AG-4a-term, agents-page.js).
|
|
392
428
|
"n.agents": "エージェント",
|
|
393
429
|
"kind.agent": "エージェント",
|
|
@@ -511,7 +511,48 @@
|
|
|
511
511
|
"ag.cancel": "Cancel",
|
|
512
512
|
"ag.started": "Started agent {id}",
|
|
513
513
|
"ag.stopped": "Stopped agent {id}",
|
|
514
|
+
"ag.stillStopping": "{id} is still stopping",
|
|
515
|
+
"ag.resumedFresh": "{id}: nothing to resume yet; started fresh",
|
|
516
|
+
"ag.inactive": "Workspace {ws} isn't active",
|
|
517
|
+
"ag.switchWs": "Switch to {ws}",
|
|
518
|
+
"ag.unknownWs": "(no workspace has that name)",
|
|
514
519
|
"ag.resumed": "Resumed agent {id}",
|
|
520
|
+
"ag.state.interrupted": "interrupted",
|
|
521
|
+
"ag.why.stopped": "Stopped by you {time}",
|
|
522
|
+
"ag.why.interrupted": "Interrupted: {name} restarted",
|
|
523
|
+
"ag.why.exited": "Exited",
|
|
524
|
+
"ag.why.exitedCode": "Exited (code {code})",
|
|
525
|
+
"ag.fresh": "Start fresh",
|
|
526
|
+
"ag.freshName": "Start {id} fresh",
|
|
527
|
+
"ag.startedFresh": "Started {id} fresh",
|
|
528
|
+
"ag.remove": "Remove",
|
|
529
|
+
"ag.removeName": "Remove {id}",
|
|
530
|
+
"ag.removed": "Removed {id}",
|
|
531
|
+
"ag.removedMany": "Removed {n} stopped agents",
|
|
532
|
+
"ag.removedMany.one": "Removed {n} stopped agent",
|
|
533
|
+
"ag.undo": "Undo",
|
|
534
|
+
"ag.restored": "Back in the list: {ids}",
|
|
535
|
+
"ag.clear": "Clear stopped",
|
|
536
|
+
"ag.noResume": "{runtime} can't resume a conversation",
|
|
537
|
+
// Session rotation (den#4).
|
|
538
|
+
"ag.rot.ready": "Ready to relaunch ({why})",
|
|
539
|
+
"ag.rot.due": "Handoff due ({why})",
|
|
540
|
+
"ag.rot.compactions": "{n} compactions",
|
|
541
|
+
"ag.rot.compactions.one": "{n} compaction",
|
|
542
|
+
"ag.rot.merged": "PR merged",
|
|
543
|
+
"ag.rot.noHandoff":
|
|
544
|
+
"Its session rotated ({why}), but it wrote no handoff.md. Resume it to write one, or start fresh.",
|
|
545
|
+
"ag.rot.badHandoff":
|
|
546
|
+
"Its session rotated ({why}), but its handoff.md is not complete. Resume it to fix it, or start fresh.",
|
|
547
|
+
"ag.relaunch": "Relaunch from handoff",
|
|
548
|
+
"ag.relaunchName": "Relaunch {id} from handoff",
|
|
549
|
+
"ag.relaunched": "Relaunched {id} from its handoff",
|
|
550
|
+
"ag.interrupted": "{n} agents were interrupted when {name} restarted",
|
|
551
|
+
"ag.interrupted.one": "{n} agent was interrupted when {name} restarted",
|
|
552
|
+
"ag.resumeAll": "Resume all",
|
|
553
|
+
"ag.review": "Review",
|
|
554
|
+
"ag.resumedAll": "Resumed {n} agents",
|
|
555
|
+
"ag.resumedAll.one": "Resumed {n} agent",
|
|
515
556
|
// The Agents tab (AG-4a-term, agents-page.js).
|
|
516
557
|
"n.agents": "Agents",
|
|
517
558
|
"kind.agent": "Agent",
|
|
@@ -1116,6 +1157,42 @@
|
|
|
1116
1157
|
if (el.remove) el.remove();
|
|
1117
1158
|
}, 2200);
|
|
1118
1159
|
}
|
|
1160
|
+
/**
|
|
1161
|
+
* A toast with one action (AG-14.1b: Remove's Undo): the message and a
|
|
1162
|
+
* small button, for `ms` (default 10 s). The action runs once, and the
|
|
1163
|
+
* toast goes.
|
|
1164
|
+
*/
|
|
1165
|
+
function toastAction(msg, label, onAction, ms) {
|
|
1166
|
+
if (!document.body || !document.createElement) return null;
|
|
1167
|
+
var old = document.querySelector && document.querySelector(".den-toast");
|
|
1168
|
+
if (old && old.remove) old.remove();
|
|
1169
|
+
var el = document.createElement("div");
|
|
1170
|
+
el.className = "den-toast den-toast-act";
|
|
1171
|
+
el.setAttribute("role", "status");
|
|
1172
|
+
var text = document.createElement("span");
|
|
1173
|
+
text.textContent = msg;
|
|
1174
|
+
var b = document.createElement("button");
|
|
1175
|
+
b.type = "button";
|
|
1176
|
+
b.className = "den-tbtn";
|
|
1177
|
+
b.textContent = label;
|
|
1178
|
+
var done = false;
|
|
1179
|
+
b.addEventListener("click", function () {
|
|
1180
|
+
if (done) return;
|
|
1181
|
+
done = true;
|
|
1182
|
+
clearTimeout(toastTimer);
|
|
1183
|
+
el.remove();
|
|
1184
|
+
onAction();
|
|
1185
|
+
});
|
|
1186
|
+
el.appendChild(text);
|
|
1187
|
+
el.appendChild(b);
|
|
1188
|
+
document.body.appendChild(el);
|
|
1189
|
+
clearTimeout(toastTimer);
|
|
1190
|
+
toastTimer = setTimeout(function () {
|
|
1191
|
+
done = true;
|
|
1192
|
+
if (el.remove) el.remove();
|
|
1193
|
+
}, ms || 10000);
|
|
1194
|
+
return el;
|
|
1195
|
+
}
|
|
1119
1196
|
|
|
1120
1197
|
// ── menus (.pop, R18): the scope parts' popovers ─────────────────────
|
|
1121
1198
|
// A menu is { label, items: [{ heading } | { hr: true } | { label, tag?,
|
|
@@ -3776,6 +3853,7 @@
|
|
|
3776
3853
|
unpin: unpin,
|
|
3777
3854
|
isPinned: isPinned,
|
|
3778
3855
|
toast: toast,
|
|
3856
|
+
toastAction: toastAction,
|
|
3779
3857
|
/** R26c: a call that resolves with its data or throws a DenApiError. */
|
|
3780
3858
|
api: api,
|
|
3781
3859
|
/** R26c: the error toast for a DenApiError (what an uncaught one gets). */
|
|
@@ -4168,6 +4246,7 @@
|
|
|
4168
4246
|
// the prototype's small buttons (.btn.sm).
|
|
4169
4247
|
".den-toast-err { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px));",
|
|
4170
4248
|
" border-color: color-mix(in srgb, var(--bad) 45%, var(--border-strong)); }",
|
|
4249
|
+
".den-toast-act { display: flex; align-items: center; gap: 10px; max-width: min(640px, calc(100vw - 32px)); }",
|
|
4171
4250
|
".den-toast-err .den-te-ic { color: var(--bad); }",
|
|
4172
4251
|
".den-toast-err .den-te-msg { display: flex; flex-direction: column; min-width: 0; overflow-wrap: anywhere; }",
|
|
4173
4252
|
".den-toast-err .den-te-id { font: 11px var(--mono); color: var(--faint); }",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* and sign-in (/api/web/*, /auth, /api/session/logout), the lock (R8:
|
|
18
18
|
* /api/sudo-password*), or a shell-grade route other than its own
|
|
19
19
|
* session's /api/sessions/<id>/*: agent start and resume mint tokens.
|
|
20
|
-
* 403 `agent_forbidden`.
|
|
20
|
+
* 403 `agent_forbidden`. One exception (AG-14.4): MCP agent_resume,
|
|
21
|
+
* when mcp.actions lists it, for another agent of its own workspace
|
|
22
|
+
* or a granted one (agent-resume-grant.ts). WS-3a (0.4.2) adds the workspace check at
|
|
21
23
|
* agentRefusal's seam.
|
|
22
24
|
* - Sign-in (§4, route-classes.ts): beyond loopback (a LAN name, a peer
|
|
23
25
|
* that isn't loopback, or the edge hostname for a client on another
|
|
@@ -58,6 +60,8 @@
|
|
|
58
60
|
*/
|
|
59
61
|
import { randomBytes, timingSafeEqual, createHash } from "crypto";
|
|
60
62
|
import type * as http from "http";
|
|
63
|
+
import { readMcpConfig } from "../../shared/den-config.js";
|
|
64
|
+
import { agentResumeRefusal, agentResumeTarget } from "./agent-resume-grant.js";
|
|
61
65
|
import * as net from "net";
|
|
62
66
|
import * as os from "os";
|
|
63
67
|
import {
|
|
@@ -707,11 +711,27 @@ export function agentRefusal(
|
|
|
707
711
|
if (id === p.session.id || id === p.session.name) return null;
|
|
708
712
|
return `${p.actor} can use its own session only, not ${id}.`;
|
|
709
713
|
}
|
|
714
|
+
// AG-14.4: MCP agent_resume, opted in, inside the agent's scope.
|
|
715
|
+
const target = method === "POST" ? agentResumeTarget(pathname) : null;
|
|
716
|
+
if (target)
|
|
717
|
+
return (
|
|
718
|
+
agentResumeRefusal(p, target.ws, target.id, mcpActions()) ??
|
|
719
|
+
workspaceRefusal(p, pathname)
|
|
720
|
+
);
|
|
710
721
|
if (cls === "shell-grade")
|
|
711
722
|
return `${p.actor} can't ${method === "GET" ? "open" : "use"} ${pathname}: starting or resuming an agent, and the browser sessions, are a person's.`;
|
|
712
723
|
return workspaceRefusal(p, pathname);
|
|
713
724
|
}
|
|
714
725
|
|
|
726
|
+
/** The org config's mcp.actions; none when it can't be read. */
|
|
727
|
+
function mcpActions(): readonly string[] {
|
|
728
|
+
try {
|
|
729
|
+
return readMcpConfig().actions;
|
|
730
|
+
} catch {
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
715
735
|
/** WS-3a's seam (0.4.2): an agent's route outside its workspace. Not yet. */
|
|
716
736
|
function workspaceRefusal(_p: AgentPrincipal, _pathname: string): null {
|
|
717
737
|
return null;
|
|
@@ -302,6 +302,32 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
302
302
|
M(["POST", "DELETE"], "/api/workspaces/services"),
|
|
303
303
|
M(["POST"], "/api/workspaces/settings"),
|
|
304
304
|
M(["POST"], "/api/workspaces/:ws/agents/:id/stop", "TERM to its session"),
|
|
305
|
+
M(
|
|
306
|
+
["POST"],
|
|
307
|
+
"/api/workspaces/:ws/agents/:id/remove",
|
|
308
|
+
"forgets a stopped agent (AG-14.1b); its record waits for Undo",
|
|
309
|
+
),
|
|
310
|
+
M(
|
|
311
|
+
["POST"],
|
|
312
|
+
"/api/agents/clear-stopped",
|
|
313
|
+
"forgets every stopped agent of the workspaces named (AG-14.1b)",
|
|
314
|
+
),
|
|
315
|
+
M(["POST"], "/api/agents/restore", "Undo: brings removed agents back"),
|
|
316
|
+
S(
|
|
317
|
+
["POST"],
|
|
318
|
+
"/api/workspaces/:ws/agents/:id/fresh",
|
|
319
|
+
"starts an agent afresh: a runtime in a PTY",
|
|
320
|
+
),
|
|
321
|
+
S(
|
|
322
|
+
["POST"],
|
|
323
|
+
"/api/workspaces/:ws/agents/:id/relaunch",
|
|
324
|
+
"relaunches a rotated agent from its handoff: a runtime in a PTY (den#4)",
|
|
325
|
+
),
|
|
326
|
+
S(
|
|
327
|
+
["POST"],
|
|
328
|
+
"/api/agents/resume-interrupted",
|
|
329
|
+
"resumes interrupted agents: runtimes in PTYs (AG-14.3)",
|
|
330
|
+
),
|
|
305
331
|
S(
|
|
306
332
|
["POST"],
|
|
307
333
|
"/api/workspaces/:ws/agents",
|
|
@@ -378,6 +404,9 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
|
|
|
378
404
|
"/api/workspaces/:ws/agents",
|
|
379
405
|
"/api/workspaces/:ws/agents/:id/stop",
|
|
380
406
|
"/api/workspaces/:ws/agents/:id/resume",
|
|
407
|
+
"/api/workspaces/:ws/agents/:id/fresh",
|
|
408
|
+
"/api/workspaces/:ws/agents/:id/relaunch",
|
|
409
|
+
"/api/workspaces/:ws/agents/:id/remove",
|
|
381
410
|
],
|
|
382
411
|
"agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
|
|
383
412
|
"session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
|
|
@@ -35,11 +35,15 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import type * as http from "http";
|
|
37
37
|
import type { Duplex } from "stream";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
getActiveWorkspaces,
|
|
40
|
+
readWorkspacesFile,
|
|
41
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
39
42
|
import { getStateDir } from "../../shared/paths.js";
|
|
40
43
|
import { product } from "../../shared/product.js";
|
|
41
44
|
import { revokedAgentIds } from "../../shared/actor-token.js";
|
|
42
45
|
import { listRecords } from "../../shared/agents/records.js";
|
|
46
|
+
import type { RotationInfo } from "../../shared/agents/rotation.js";
|
|
43
47
|
import {
|
|
44
48
|
connectHost,
|
|
45
49
|
noHost,
|
|
@@ -49,7 +53,7 @@ import { parseSessionName } from "../../shared/sessions/names.js";
|
|
|
49
53
|
import { sessionPaths } from "../../shared/sessions/paths.js";
|
|
50
54
|
import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
|
|
51
55
|
import type { SessionInfo } from "../../shared/sessions/types.js";
|
|
52
|
-
import { agentRows } from "../cli/agent-ops.js";
|
|
56
|
+
import { agentRows, noteEnded } from "../cli/agent-ops.js";
|
|
53
57
|
import { state as sessionState } from "../cli/sessions-command.js";
|
|
54
58
|
import { recordAction } from "./audit.js";
|
|
55
59
|
import { sendJson } from "./http-helpers.js";
|
|
@@ -68,6 +72,8 @@ export interface SessionRouteDeps {
|
|
|
68
72
|
/** A client of the running host, or null (never starts one). */
|
|
69
73
|
connectExisting?: () => Promise<HostClient | null>;
|
|
70
74
|
activeWorkspaces?: () => string[];
|
|
75
|
+
/** Whether workspaces.yml names `ws` (active or not). */
|
|
76
|
+
workspaceExists?: (ws: string) => boolean;
|
|
71
77
|
pty?: () => PtyLoad;
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -76,9 +82,13 @@ interface Resolved {
|
|
|
76
82
|
dist: () => string;
|
|
77
83
|
connectExisting: () => Promise<HostClient | null>;
|
|
78
84
|
activeWorkspaces: () => string[];
|
|
85
|
+
workspaceExists: (ws: string) => boolean;
|
|
79
86
|
pty: () => PtyLoad;
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
/** A workspace name as `?ws=` may give it. */
|
|
90
|
+
const WS_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
91
|
+
|
|
82
92
|
function resolve(d: SessionRouteDeps = {}): Resolved {
|
|
83
93
|
const stateDir = d.stateDir ?? (() => getStateDir());
|
|
84
94
|
return {
|
|
@@ -95,6 +105,13 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
|
|
|
95
105
|
}
|
|
96
106
|
}),
|
|
97
107
|
activeWorkspaces: d.activeWorkspaces ?? (() => getActiveWorkspaces()),
|
|
108
|
+
workspaceExists:
|
|
109
|
+
d.workspaceExists ??
|
|
110
|
+
((ws) =>
|
|
111
|
+
Object.prototype.hasOwnProperty.call(
|
|
112
|
+
readWorkspacesFile().workspaces ?? {},
|
|
113
|
+
ws,
|
|
114
|
+
)),
|
|
98
115
|
pty: d.pty ?? (() => loadPty()),
|
|
99
116
|
};
|
|
100
117
|
}
|
|
@@ -119,7 +136,7 @@ export interface ListedSession {
|
|
|
119
136
|
runtime?: string;
|
|
120
137
|
model?: string;
|
|
121
138
|
cwd?: string;
|
|
122
|
-
/** running, stopping, exited (<code>), stopped,
|
|
139
|
+
/** running, stopping, exited (<code>), stopped, interrupted (AG-14). */
|
|
123
140
|
state: string;
|
|
124
141
|
exited: boolean;
|
|
125
142
|
exitCode?: number;
|
|
@@ -127,19 +144,33 @@ export interface ListedSession {
|
|
|
127
144
|
/** The host has it (running, or ended with its scrollback): a pane can attach. */
|
|
128
145
|
attachable: boolean;
|
|
129
146
|
resumable?: boolean;
|
|
147
|
+
/** Why only Start fresh applies (AG-14.2): "<runtime> can't resume a conversation". */
|
|
148
|
+
noResume?: string;
|
|
149
|
+
/** When it was stopped, seen to end, or found interrupted (AG-14.1). */
|
|
150
|
+
stoppedAt?: string;
|
|
151
|
+
endedAt?: string;
|
|
152
|
+
interruptedAt?: string;
|
|
130
153
|
/** Its actor token was revoked (`web logout --all`): restart or resume it. */
|
|
131
154
|
tokenRevoked?: boolean;
|
|
132
155
|
/** An approval is pending (AG-6a; only when the host knows). */
|
|
133
156
|
waiting?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* Session rotation (den#4): why it rotates and its handoff.md. `ready`:
|
|
159
|
+
* Relaunch from handoff is its action, not Resume.
|
|
160
|
+
*/
|
|
161
|
+
rotation?: RotationInfo;
|
|
134
162
|
}
|
|
135
163
|
|
|
136
164
|
/** The list, from the host's sessions and the agent records. */
|
|
137
165
|
export function listedSessions(
|
|
138
166
|
sessions: SessionInfo[],
|
|
139
167
|
d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
|
|
168
|
+
/** A workspace asked for by name (`?ws=`), listed even when inactive. */
|
|
169
|
+
also?: string,
|
|
140
170
|
): ListedSession[] {
|
|
141
171
|
const dist = d.dist();
|
|
142
172
|
const active = new Set(d.activeWorkspaces());
|
|
173
|
+
if (also) active.add(also);
|
|
143
174
|
const mine = sessions.filter((s) => {
|
|
144
175
|
const p = parseSessionName(s.name, dist);
|
|
145
176
|
return p !== null && active.has(p.workspace);
|
|
@@ -150,7 +181,8 @@ export function listedSessions(
|
|
|
150
181
|
const revoked = revokedAgentIds(d.stateDir());
|
|
151
182
|
const out: ListedSession[] = [];
|
|
152
183
|
const agentSessions = new Set<string>();
|
|
153
|
-
|
|
184
|
+
noteEnded(d.stateDir(), mine, records);
|
|
185
|
+
for (const r of agentRows(mine, records, d.stateDir())) {
|
|
154
186
|
const rec = records.find((x) => x.id === r.agent)!;
|
|
155
187
|
const s = r.session;
|
|
156
188
|
if (s) agentSessions.add(s.ref.id);
|
|
@@ -171,6 +203,11 @@ export function listedSessions(
|
|
|
171
203
|
...(r.startedAt ? { startedAt: r.startedAt } : {}),
|
|
172
204
|
attachable: Boolean(s),
|
|
173
205
|
resumable: r.resumable,
|
|
206
|
+
...(r.noResume ? { noResume: r.noResume } : {}),
|
|
207
|
+
...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
|
|
208
|
+
...(r.endedAt ? { endedAt: r.endedAt } : {}),
|
|
209
|
+
...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
|
|
210
|
+
...(r.rotation?.due ? { rotation: r.rotation } : {}),
|
|
174
211
|
...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
|
|
175
212
|
});
|
|
176
213
|
}
|
|
@@ -235,9 +272,27 @@ export async function handleSessionList(
|
|
|
235
272
|
} catch (e) {
|
|
236
273
|
hostError = (e as Error).message;
|
|
237
274
|
}
|
|
275
|
+
// `?ws=<name>`: that workspace's too, active or not, and whether it is
|
|
276
|
+
// active and known (the page says so rather than show all of them).
|
|
277
|
+
const asked = new URL(req.url ?? "/", "http://localhost").searchParams.get(
|
|
278
|
+
"ws",
|
|
279
|
+
);
|
|
280
|
+
const wanted =
|
|
281
|
+
asked && WS_NAME.test(asked)
|
|
282
|
+
? {
|
|
283
|
+
workspace: asked,
|
|
284
|
+
active: d.activeWorkspaces().includes(asked),
|
|
285
|
+
known: d.workspaceExists(asked),
|
|
286
|
+
}
|
|
287
|
+
: null;
|
|
238
288
|
sendJson(res, {
|
|
239
|
-
sessions: listedSessions(
|
|
289
|
+
sessions: listedSessions(
|
|
290
|
+
sessions,
|
|
291
|
+
d,
|
|
292
|
+
wanted?.known ? wanted.workspace : undefined,
|
|
293
|
+
),
|
|
240
294
|
workspaces: d.activeWorkspaces(),
|
|
295
|
+
...(wanted ? { requested: wanted } : {}),
|
|
241
296
|
host,
|
|
242
297
|
...(hostError ? { hostError } : {}),
|
|
243
298
|
backend: pty.available
|
|
@@ -32,7 +32,7 @@ import { AsyncLocalStorage } from "async_hooks";
|
|
|
32
32
|
import * as fs from "fs";
|
|
33
33
|
import * as os from "os";
|
|
34
34
|
import * as path from "path";
|
|
35
|
-
import {
|
|
35
|
+
import { execFileSync } from "child_process";
|
|
36
36
|
import { CLI_SPAWN_TIMEOUT_MS } from "../../shared/constants.js";
|
|
37
37
|
|
|
38
38
|
import { product } from "../../shared/product.js";
|
|
@@ -62,11 +62,40 @@ function shimDirPrefix(): string {
|
|
|
62
62
|
return path.join(os.tmpdir(), `${product().cli}-sudo-`);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The system's sudo: what Den runs whenever it is there. */
|
|
66
|
+
export const SYSTEM_SUDO = "/usr/bin/sudo";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the system's sudo is: /usr/bin/sudo, or DEN_SYSTEM_SUDO when set
|
|
70
|
+
* to an absolute path (a machine whose sudo lives elsewhere, and the
|
|
71
|
+
* tests' fake sudo). Never a name looked up on PATH.
|
|
72
|
+
*/
|
|
73
|
+
export function systemSudo(env: NodeJS.ProcessEnv = process.env): string {
|
|
74
|
+
const v = env.DEN_SYSTEM_SUDO;
|
|
75
|
+
return v && path.isAbsolute(v) ? v : SYSTEM_SUDO;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isExecutableFile(file: string): boolean {
|
|
79
|
+
try {
|
|
80
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
81
|
+
return fs.statSync(file).isFile();
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
65
87
|
/**
|
|
66
|
-
* The real sudo:
|
|
67
|
-
*
|
|
88
|
+
* The real sudo: /usr/bin/sudo (systemSudo), pinned, so a `sudo` earlier
|
|
89
|
+
* on PATH (a directory a user or a tool can write to) never receives the
|
|
90
|
+
* admin password. Only where there is none there: the first `sudo` on
|
|
91
|
+
* PATH that isn't one of Den's shims. Read when a shim is written, and by
|
|
92
|
+
* every check Den runs itself.
|
|
68
93
|
*/
|
|
69
|
-
export function realSudoPath(
|
|
94
|
+
export function realSudoPath(
|
|
95
|
+
envPath: string = process.env.PATH ?? "",
|
|
96
|
+
system: string = systemSudo(),
|
|
97
|
+
): string {
|
|
98
|
+
if (isExecutableFile(system)) return system;
|
|
70
99
|
const prefix = shimDirPrefix();
|
|
71
100
|
for (const dir of envPath.split(path.delimiter)) {
|
|
72
101
|
if (!dir || dir.startsWith(prefix)) continue;
|
|
@@ -78,7 +107,7 @@ export function realSudoPath(envPath: string = process.env.PATH ?? ""): string {
|
|
|
78
107
|
/* not here */
|
|
79
108
|
}
|
|
80
109
|
}
|
|
81
|
-
return
|
|
110
|
+
return system;
|
|
82
111
|
}
|
|
83
112
|
|
|
84
113
|
/** A path inside single quotes, for a shim. */
|
|
@@ -254,7 +283,7 @@ export function watchSudo(
|
|
|
254
283
|
export function prewarmSudo(): void {
|
|
255
284
|
if (!_sudoPassword || lockClosedHere()) return;
|
|
256
285
|
try {
|
|
257
|
-
|
|
286
|
+
execFileSync(realSudoPath(), ["-S", "-v"], {
|
|
258
287
|
input: _sudoPassword + "\n",
|
|
259
288
|
stdio: ["pipe", "pipe", "pipe"],
|
|
260
289
|
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
@@ -288,7 +317,7 @@ export function hasPasswordlessSudo(): boolean {
|
|
|
288
317
|
}
|
|
289
318
|
let value = false;
|
|
290
319
|
try {
|
|
291
|
-
|
|
320
|
+
execFileSync(realSudoPath(), ["-n", "true"], {
|
|
292
321
|
stdio: ["ignore", "ignore", "ignore"],
|
|
293
322
|
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
294
323
|
});
|