@monster-orange/den 0.3.0 → 0.4.0
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 +184 -0
- package/docs/distributions.md +229 -8
- package/package.json +4 -1
- package/src/shared/agents/default-pack/be-dev.md +20 -0
- package/src/shared/agents/default-pack/devops.md +14 -0
- package/src/shared/agents/default-pack/fe-dev.md +20 -0
- package/src/shared/agents/default-pack/orchestrator.md +16 -0
- package/src/shared/agents/default-pack/planner.md +17 -0
- package/src/shared/agents/default-pack/reviewer.md +14 -0
- package/src/shared/agents/default-pack/ux-designer.md +15 -0
- package/src/shared/agents/den-claude.ts +173 -0
- package/src/shared/agents/generic.ts +52 -0
- package/src/shared/agents/personas.ts +218 -0
- package/src/shared/agents/records.ts +118 -0
- package/src/shared/agents/runtime.ts +192 -0
- package/src/shared/agents/runtimes.ts +47 -0
- package/src/shared/den-config.ts +116 -0
- package/src/shared/engine.ts +23 -0
- package/src/shared/environment-manager/workspaces.ts +19 -0
- package/src/shared/secret-env.ts +70 -0
- package/src/shared/sessions/client.ts +277 -0
- package/src/shared/sessions/den-pty.ts +97 -0
- package/src/shared/sessions/env.ts +79 -0
- package/src/shared/sessions/host.ts +667 -0
- package/src/shared/sessions/names.ts +85 -0
- package/src/shared/sessions/paths.ts +27 -0
- package/src/shared/sessions/protocol.ts +72 -0
- package/src/shared/sessions/pty.ts +141 -0
- package/src/shared/sessions/ring-buffer.ts +160 -0
- package/src/shared/sessions/types.ts +81 -0
- package/src/ui/cli/agents-command.ts +812 -0
- package/src/ui/cli/doctor-command.ts +138 -9
- package/src/ui/cli/env-probe.ts +266 -0
- package/src/ui/cli/flag-parser.ts +11 -3
- package/src/ui/cli/index.ts +34 -2
- package/src/ui/cli/sessions-command.ts +718 -0
- package/src/ui/mcp/scope.ts +123 -19
- package/src/ui/mcp/server.ts +27 -2
- package/src/ui/mcp/tools.ts +44 -5
- package/src/ui/server/audit.ts +35 -0
- package/src/ui/server/index.ts +7 -0
- package/src/ui/server/readonly-routes.ts +25 -2
- package/src/ui/server/workspace-routes.ts +3 -15
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session names: `<dist>-<workspace>:<name>` (AG spec §2, MD-1). The
|
|
3
|
+
* distribution prefix keeps two distributions apart on one machine; the
|
|
4
|
+
* workspace part is what scopes a list to one workspace (§5c gate).
|
|
5
|
+
*/
|
|
6
|
+
import { WORKSPACE_NAME_RE } from "../agent-actor.js";
|
|
7
|
+
import type { SessionInfo } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/** A session's own name: the same rules as a workspace name. */
|
|
10
|
+
export const SESSION_NAME_RE = WORKSPACE_NAME_RE;
|
|
11
|
+
|
|
12
|
+
export function sessionName(
|
|
13
|
+
dist: string,
|
|
14
|
+
workspace: string,
|
|
15
|
+
name: string,
|
|
16
|
+
): string {
|
|
17
|
+
if (!WORKSPACE_NAME_RE.test(workspace))
|
|
18
|
+
throw new Error(
|
|
19
|
+
`workspace "${workspace}" is not a workspace name (letters, digits, ".", "_", "-"; 1-64 long)`,
|
|
20
|
+
);
|
|
21
|
+
if (!SESSION_NAME_RE.test(name))
|
|
22
|
+
throw new Error(
|
|
23
|
+
`session name "${name}" must be letters, digits, ".", "_" or "-", 1-64 long, starting with a letter or digit`,
|
|
24
|
+
);
|
|
25
|
+
return `${dist}-${workspace}:${name}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The workspace and name in `full`, or null when it isn't one of `dist`'s. */
|
|
29
|
+
export function parseSessionName(
|
|
30
|
+
full: string,
|
|
31
|
+
dist: string,
|
|
32
|
+
): { workspace: string; name: string } | null {
|
|
33
|
+
const prefix = `${dist}-`;
|
|
34
|
+
if (!full.startsWith(prefix)) return null;
|
|
35
|
+
const rest = full.slice(prefix.length);
|
|
36
|
+
const colon = rest.lastIndexOf(":");
|
|
37
|
+
if (colon <= 0) return null;
|
|
38
|
+
const workspace = rest.slice(0, colon);
|
|
39
|
+
const name = rest.slice(colon + 1);
|
|
40
|
+
if (!WORKSPACE_NAME_RE.test(workspace) || !SESSION_NAME_RE.test(name))
|
|
41
|
+
return null;
|
|
42
|
+
return { workspace, name };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether `s` belongs to `workspace` of `dist`. */
|
|
46
|
+
export function inWorkspace(
|
|
47
|
+
s: Pick<SessionInfo, "name">,
|
|
48
|
+
dist: string,
|
|
49
|
+
workspace: string,
|
|
50
|
+
): boolean {
|
|
51
|
+
return parseSessionName(s.name, dist)?.workspace === workspace;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The session `target` names: an id, a full name, `<workspace>:<name>`, or
|
|
56
|
+
* a bare name that only one session has (within `workspace` when given). A
|
|
57
|
+
* running session wins over an exited one of the same name.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveSession(
|
|
60
|
+
sessions: SessionInfo[],
|
|
61
|
+
dist: string,
|
|
62
|
+
target: string,
|
|
63
|
+
workspace?: string,
|
|
64
|
+
): SessionInfo {
|
|
65
|
+
const byId = sessions.find((s) => s.ref.id === target);
|
|
66
|
+
if (byId) return byId;
|
|
67
|
+
const matches = sessions.filter((s) => {
|
|
68
|
+
const p = parseSessionName(s.name, dist);
|
|
69
|
+
if (!p) return s.name === target;
|
|
70
|
+
if (s.name === target) return true;
|
|
71
|
+
if (`${p.workspace}:${p.name}` === target) return true;
|
|
72
|
+
return p.name === target && (!workspace || p.workspace === workspace);
|
|
73
|
+
});
|
|
74
|
+
const running = matches.filter((s) => !s.exited);
|
|
75
|
+
const pool = running.length ? running : matches;
|
|
76
|
+
const names = [...new Set(pool.map((s) => s.name))];
|
|
77
|
+
if (names.length === 1) return pool[pool.length - 1];
|
|
78
|
+
if (!names.length)
|
|
79
|
+
throw new Error(
|
|
80
|
+
`no session "${target}"${workspace ? ` in ${workspace}` : ""}`,
|
|
81
|
+
);
|
|
82
|
+
throw new Error(
|
|
83
|
+
`"${target}" names more than one session: ${names.sort().join(", ")}. Give the full name or the id.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the session host keeps its files, in the distribution's state dir
|
|
3
|
+
* (getStateDir: the profile's ~/.<name>, or <PREFIX>_HOME):
|
|
4
|
+
*
|
|
5
|
+
* sessions.sock the host's unix socket (0600)
|
|
6
|
+
* sessions.lock held only while a host starts
|
|
7
|
+
* sessions/<id>.json a session's record (no argv, no env)
|
|
8
|
+
* sessions/<id>.log its scrollback (0600)
|
|
9
|
+
*/
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
|
|
12
|
+
export interface SessionPaths {
|
|
13
|
+
socket: string;
|
|
14
|
+
lock: string;
|
|
15
|
+
dir: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sessionPaths(stateDir: string): SessionPaths {
|
|
19
|
+
return {
|
|
20
|
+
socket: path.join(stateDir, "sessions.sock"),
|
|
21
|
+
lock: path.join(stateDir, "sessions.lock"),
|
|
22
|
+
dir: path.join(stateDir, "sessions"),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A unix socket path's limit, in bytes (macOS's sun_path is 104, with NUL). */
|
|
27
|
+
export const MAX_SOCKET_PATH = 103;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session host's wire protocol, version 1: newline-delimited JSON over
|
|
3
|
+
* the host's unix socket, both ways. Terminal bytes travel base64 in `data`.
|
|
4
|
+
*
|
|
5
|
+
* request {v, id, op, ...} client → host
|
|
6
|
+
* reply {id, ok: true, ...} host → client, one per request
|
|
7
|
+
* {id, ok: false, code, error}
|
|
8
|
+
* event {ev: "data", aid, data} host → client, per attachment
|
|
9
|
+
* {ev: "exit", aid, exitCode}
|
|
10
|
+
*
|
|
11
|
+
* ops: hello, start, list, attach, detach, write, resize, signal,
|
|
12
|
+
* scrollback, forget, shutdown. A request whose `v` is not the host's is
|
|
13
|
+
* refused with code "protocol", except `shutdown`, which every version
|
|
14
|
+
* takes, so an older host can always be stopped.
|
|
15
|
+
*
|
|
16
|
+
* An attach reply comes after its replay: once a client has the reply, the
|
|
17
|
+
* scrollback has been delivered and live output follows, with no gap and
|
|
18
|
+
* nothing twice.
|
|
19
|
+
*/
|
|
20
|
+
import { StringDecoder } from "string_decoder";
|
|
21
|
+
|
|
22
|
+
export const PROTOCOL_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
/** A line longer than this ends the connection (a replay is ~2.7 MB). */
|
|
25
|
+
export const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
export type HostErrorCode =
|
|
28
|
+
| "protocol"
|
|
29
|
+
| "bad-request"
|
|
30
|
+
| "not-found"
|
|
31
|
+
| "exists"
|
|
32
|
+
| "cap"
|
|
33
|
+
| "unavailable"
|
|
34
|
+
| "spawn"
|
|
35
|
+
| "exited";
|
|
36
|
+
|
|
37
|
+
export interface Request {
|
|
38
|
+
v: number;
|
|
39
|
+
id: number;
|
|
40
|
+
op: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type Reply =
|
|
45
|
+
| ({ id: number; ok: true } & Record<string, unknown>)
|
|
46
|
+
| { id: number; ok: false; code: HostErrorCode; error: string };
|
|
47
|
+
|
|
48
|
+
export type HostEvent =
|
|
49
|
+
| { ev: "data"; aid: string; data: string }
|
|
50
|
+
| { ev: "exit"; aid: string; exitCode?: number };
|
|
51
|
+
|
|
52
|
+
/** Splits a byte stream into lines; refuses one that grows too long. */
|
|
53
|
+
export class LineReader {
|
|
54
|
+
private buf = "";
|
|
55
|
+
private readonly decoder = new StringDecoder("utf8");
|
|
56
|
+
|
|
57
|
+
constructor(private readonly onLine: (line: string) => void) {}
|
|
58
|
+
|
|
59
|
+
/** Returns false once a line is too long: drop the connection. */
|
|
60
|
+
push(chunk: Buffer | string): boolean {
|
|
61
|
+
this.buf += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
|
62
|
+
let nl: number;
|
|
63
|
+
while ((nl = this.buf.indexOf("\n")) !== -1) {
|
|
64
|
+
const line = this.buf.slice(0, nl);
|
|
65
|
+
this.buf = this.buf.slice(nl + 1);
|
|
66
|
+
if (line.trim()) this.onLine(line);
|
|
67
|
+
}
|
|
68
|
+
return this.buf.length <= MAX_LINE_BYTES;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const frame = (o: unknown): string => JSON.stringify(o) + "\n";
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PTY seam for den-pty (AG-3a, Q-AG-1).
|
|
3
|
+
*
|
|
4
|
+
* A real terminal needs a native module: @lydell/node-pty, an
|
|
5
|
+
* optionalDependency at an exact version. Its binaries are prebuilt and
|
|
6
|
+
* ship in per-platform packages npm installs alongside it (no install
|
|
7
|
+
* script, nothing downloaded after install). When it is missing (an
|
|
8
|
+
* `--omit=optional` install, an unsupported platform, a node_modules copied
|
|
9
|
+
* across operating systems), loadPty says why instead of throwing, and
|
|
10
|
+
* den-pty reports itself unavailable with that reason (`<cli> doctor`).
|
|
11
|
+
*
|
|
12
|
+
* The host only ever sees PtyFactory, so tests run a scripted or piped
|
|
13
|
+
* stand-in and never need the module.
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { getEngineRoot } from "../paths.js";
|
|
18
|
+
|
|
19
|
+
export const PTY_MODULE = "@lydell/node-pty";
|
|
20
|
+
|
|
21
|
+
export interface PtySpawnOptions {
|
|
22
|
+
cwd: string;
|
|
23
|
+
env: Record<string, string>;
|
|
24
|
+
cols: number;
|
|
25
|
+
rows: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PtyExit {
|
|
29
|
+
exitCode: number;
|
|
30
|
+
signal?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the host needs of a terminal. Data is bytes both ways. */
|
|
34
|
+
export interface PtyProcess {
|
|
35
|
+
readonly pid: number;
|
|
36
|
+
onData(fn: (data: Buffer) => void): void;
|
|
37
|
+
onExit(fn: (e: PtyExit) => void): void;
|
|
38
|
+
write(data: Buffer): void;
|
|
39
|
+
resize(cols: number, rows: number): void;
|
|
40
|
+
/** `signal` is a Node signal name ("SIGTERM"). */
|
|
41
|
+
kill(signal: string): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type PtyFactory = (
|
|
45
|
+
file: string,
|
|
46
|
+
args: string[],
|
|
47
|
+
opts: PtySpawnOptions,
|
|
48
|
+
) => PtyProcess;
|
|
49
|
+
|
|
50
|
+
export type PtyLoad =
|
|
51
|
+
| { available: true; factory: PtyFactory }
|
|
52
|
+
| { available: false; reason: string };
|
|
53
|
+
|
|
54
|
+
/** The subset of the module's API that is used. */
|
|
55
|
+
interface NativePty {
|
|
56
|
+
pid: number;
|
|
57
|
+
onData(fn: (d: string | Buffer) => void): unknown;
|
|
58
|
+
onExit(fn: (e: { exitCode: number; signal?: number }) => void): unknown;
|
|
59
|
+
write(d: string | Buffer): void;
|
|
60
|
+
resize(cols: number, rows: number): void;
|
|
61
|
+
kill(signal?: string): void;
|
|
62
|
+
}
|
|
63
|
+
interface NativeModule {
|
|
64
|
+
spawn(file: string, args: string[], opts: Record<string, unknown>): NativePty;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolve modules as Den's own package does (hoisted in a distribution). */
|
|
68
|
+
function engineRequire(id: string): unknown {
|
|
69
|
+
return createRequire(path.join(getEngineRoot(), "package.json"))(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function wrap(mod: NativeModule): PtyFactory {
|
|
73
|
+
return (file, args, opts) => {
|
|
74
|
+
const p = mod.spawn(file, args, {
|
|
75
|
+
name: opts.env.TERM || "xterm-256color",
|
|
76
|
+
cols: opts.cols,
|
|
77
|
+
rows: opts.rows,
|
|
78
|
+
cwd: opts.cwd,
|
|
79
|
+
env: opts.env,
|
|
80
|
+
// Bytes, not strings: output is relayed as it came.
|
|
81
|
+
encoding: null,
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
pid: p.pid,
|
|
85
|
+
onData: (fn) =>
|
|
86
|
+
void p.onData((d) => fn(Buffer.isBuffer(d) ? d : Buffer.from(d))),
|
|
87
|
+
onExit: (fn) =>
|
|
88
|
+
void p.onExit((e) => fn({ exitCode: e.exitCode, signal: e.signal })),
|
|
89
|
+
write: (d) => p.write(d),
|
|
90
|
+
resize: (c, r) => p.resize(c, r),
|
|
91
|
+
// The session leads its own process group: signal all of it, so a
|
|
92
|
+
// shell's foreground job gets the INT too.
|
|
93
|
+
kill: (signal) => {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(-p.pid, signal as NodeJS.Signals);
|
|
96
|
+
} catch {
|
|
97
|
+
try {
|
|
98
|
+
p.kill(signal);
|
|
99
|
+
} catch {
|
|
100
|
+
/* already gone */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let memo: PtyLoad | null = null;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The PTY module, or why it can't be used. `req` is for tests; the default
|
|
112
|
+
* resolves from Den's package and is remembered.
|
|
113
|
+
*/
|
|
114
|
+
export function loadPty(req?: (id: string) => unknown): PtyLoad {
|
|
115
|
+
if (!req && memo) return memo;
|
|
116
|
+
let result: PtyLoad;
|
|
117
|
+
try {
|
|
118
|
+
const mod = (req ?? engineRequire)(PTY_MODULE) as Partial<NativeModule>;
|
|
119
|
+
result =
|
|
120
|
+
typeof mod?.spawn === "function"
|
|
121
|
+
? { available: true, factory: wrap(mod as NativeModule) }
|
|
122
|
+
: {
|
|
123
|
+
available: false,
|
|
124
|
+
reason: `${PTY_MODULE} loaded but has no spawn(): not a PTY module Den knows`,
|
|
125
|
+
};
|
|
126
|
+
} catch (e) {
|
|
127
|
+
const err = e as NodeJS.ErrnoException;
|
|
128
|
+
const own =
|
|
129
|
+
err.code === "MODULE_NOT_FOUND" &&
|
|
130
|
+
String(err.message).includes(`'${PTY_MODULE}'`);
|
|
131
|
+
result = {
|
|
132
|
+
available: false,
|
|
133
|
+
reason: own
|
|
134
|
+
? `${PTY_MODULE} is not installed (an optional dependency of Den; ` +
|
|
135
|
+
`reinstall without --omit=optional, on a platform it ships binaries for)`
|
|
136
|
+
: String(err.message).split("\n")[0].trim(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (!req) memo = result;
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrollback (AG spec §3): a ring buffer per session that keeps the newest
|
|
3
|
+
* bytes (default 2 MB), and its log at `<stateDir>/sessions/<id>.log`, so a
|
|
4
|
+
* host that restarted can still replay a session's history.
|
|
5
|
+
*
|
|
6
|
+
* The log is appended to as output arrives (flushed every `flushMs`), and
|
|
7
|
+
* rewritten from the ring once it passes twice the ring's capacity, so it
|
|
8
|
+
* never grows without bound. A truncation can split a multi-byte character
|
|
9
|
+
* or an escape sequence at the very start of a replay; a terminal shrugs
|
|
10
|
+
* that off.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export class ScrollbackRing {
|
|
17
|
+
private buf: Buffer;
|
|
18
|
+
/** Index of the oldest byte. */
|
|
19
|
+
private start = 0;
|
|
20
|
+
private length = 0;
|
|
21
|
+
private droppedBytes = 0;
|
|
22
|
+
|
|
23
|
+
constructor(readonly capacity: number = DEFAULT_SCROLLBACK_BYTES) {
|
|
24
|
+
if (!Number.isInteger(capacity) || capacity < 1)
|
|
25
|
+
throw new Error(`scrollback capacity must be a positive whole number`);
|
|
26
|
+
this.buf = Buffer.alloc(capacity);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get size(): number {
|
|
30
|
+
return this.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bytes pushed out of the ring so far. */
|
|
34
|
+
get dropped(): number {
|
|
35
|
+
return this.droppedBytes;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
push(chunk: Uint8Array): void {
|
|
39
|
+
let data = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
40
|
+
if (data.length >= this.capacity) {
|
|
41
|
+
this.droppedBytes += this.length + data.length - this.capacity;
|
|
42
|
+
data = data.subarray(data.length - this.capacity);
|
|
43
|
+
data.copy(this.buf, 0);
|
|
44
|
+
this.start = 0;
|
|
45
|
+
this.length = this.capacity;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const overflow = this.length + data.length - this.capacity;
|
|
49
|
+
if (overflow > 0) {
|
|
50
|
+
this.start = (this.start + overflow) % this.capacity;
|
|
51
|
+
this.length -= overflow;
|
|
52
|
+
this.droppedBytes += overflow;
|
|
53
|
+
}
|
|
54
|
+
const end = (this.start + this.length) % this.capacity;
|
|
55
|
+
const first = Math.min(data.length, this.capacity - end);
|
|
56
|
+
data.copy(this.buf, end, 0, first);
|
|
57
|
+
if (first < data.length) data.copy(this.buf, 0, first);
|
|
58
|
+
this.length += data.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The newest `maxBytes` (all of it by default), oldest first. */
|
|
62
|
+
snapshot(maxBytes: number = this.capacity): Buffer {
|
|
63
|
+
const n = Math.max(0, Math.min(maxBytes, this.length));
|
|
64
|
+
const from = (this.start + this.length - n) % this.capacity;
|
|
65
|
+
if (from + n <= this.capacity)
|
|
66
|
+
return Buffer.from(this.buf.subarray(from, from + n));
|
|
67
|
+
return Buffer.concat([
|
|
68
|
+
this.buf.subarray(from),
|
|
69
|
+
this.buf.subarray(0, n - (this.capacity - from)),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ScrollbackLogOptions {
|
|
75
|
+
/** How long output waits before it is written; 0 writes on flush() only. */
|
|
76
|
+
flushMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class ScrollbackLog {
|
|
80
|
+
private pending: Buffer[] = [];
|
|
81
|
+
private timer: NodeJS.Timeout | null = null;
|
|
82
|
+
private written: number;
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
readonly file: string,
|
|
86
|
+
readonly ring: ScrollbackRing,
|
|
87
|
+
private readonly opts: ScrollbackLogOptions = {},
|
|
88
|
+
) {
|
|
89
|
+
try {
|
|
90
|
+
this.written = fs.statSync(file).size;
|
|
91
|
+
} catch {
|
|
92
|
+
this.written = 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A ring holding the newest `capacity` bytes of `file` (empty if none). */
|
|
97
|
+
static load(
|
|
98
|
+
file: string,
|
|
99
|
+
capacity: number = DEFAULT_SCROLLBACK_BYTES,
|
|
100
|
+
): ScrollbackRing {
|
|
101
|
+
const ring = new ScrollbackRing(capacity);
|
|
102
|
+
let fd: number;
|
|
103
|
+
try {
|
|
104
|
+
fd = fs.openSync(file, "r");
|
|
105
|
+
} catch {
|
|
106
|
+
return ring;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const size = fs.fstatSync(fd).size;
|
|
110
|
+
const n = Math.min(size, capacity);
|
|
111
|
+
const buf = Buffer.alloc(n);
|
|
112
|
+
fs.readSync(fd, buf, 0, n, size - n);
|
|
113
|
+
ring.push(buf);
|
|
114
|
+
} finally {
|
|
115
|
+
fs.closeSync(fd);
|
|
116
|
+
}
|
|
117
|
+
return ring;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
append(chunk: Uint8Array): void {
|
|
121
|
+
this.ring.push(chunk);
|
|
122
|
+
this.pending.push(Buffer.from(chunk));
|
|
123
|
+
const ms = this.opts.flushMs ?? 200;
|
|
124
|
+
if (ms > 0 && !this.timer) {
|
|
125
|
+
this.timer = setTimeout(() => {
|
|
126
|
+
this.timer = null;
|
|
127
|
+
this.flush();
|
|
128
|
+
}, ms);
|
|
129
|
+
this.timer.unref();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Write what is pending; rewrite the log from the ring once it is too big. */
|
|
134
|
+
flush(): void {
|
|
135
|
+
if (this.timer) {
|
|
136
|
+
clearTimeout(this.timer);
|
|
137
|
+
this.timer = null;
|
|
138
|
+
}
|
|
139
|
+
if (!this.pending.length) return;
|
|
140
|
+
const data = Buffer.concat(this.pending);
|
|
141
|
+
this.pending = [];
|
|
142
|
+
try {
|
|
143
|
+
if (this.written + data.length > 2 * this.ring.capacity) {
|
|
144
|
+
const all = this.ring.snapshot();
|
|
145
|
+
fs.writeFileSync(this.file, all, { mode: 0o600 });
|
|
146
|
+
this.written = all.length;
|
|
147
|
+
} else {
|
|
148
|
+
fs.appendFileSync(this.file, data, { mode: 0o600 });
|
|
149
|
+
this.written += data.length;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
/* the ring still has it; the next flush tries again from there */
|
|
153
|
+
this.written = Number.POSITIVE_INFINITY;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
close(): void {
|
|
158
|
+
this.flush();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session backend contract (strategy kind `session-backend`, AG spec
|
|
3
|
+
* §2). A backend starts an argv in a cwd with an env and keeps its PTY; it
|
|
4
|
+
* knows nothing about agents, personas or runtimes. Den maps a session to
|
|
5
|
+
* its agent (AG-2a); the backend never does.
|
|
6
|
+
*
|
|
7
|
+
* Session names are `<dist>-<workspace>:<name>` (names.ts), so two
|
|
8
|
+
* distributions on one machine never collide.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Which backend holds a session, and its id there. */
|
|
12
|
+
export interface SessionRef {
|
|
13
|
+
backend: string;
|
|
14
|
+
id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A session as a backend lists it. */
|
|
18
|
+
export interface SessionInfo {
|
|
19
|
+
ref: SessionRef;
|
|
20
|
+
/** `<dist>-<workspace>:<name>`. */
|
|
21
|
+
name: string;
|
|
22
|
+
pid: number;
|
|
23
|
+
/** ISO 8601. */
|
|
24
|
+
startedAt: string;
|
|
25
|
+
exited?: boolean;
|
|
26
|
+
exitCode?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Set once input ending a line (Enter) has been written to it: someone
|
|
29
|
+
* prompted it. A runtime with nothing to resume until then (den-claude)
|
|
30
|
+
* starts afresh instead. Never what was typed.
|
|
31
|
+
*/
|
|
32
|
+
prompted?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What to run. `env` is the whole environment: nothing is inherited. */
|
|
36
|
+
export interface SessionSpec {
|
|
37
|
+
name: string;
|
|
38
|
+
cwd: string;
|
|
39
|
+
argv: string[];
|
|
40
|
+
env: Record<string, string>;
|
|
41
|
+
cols: number;
|
|
42
|
+
rows: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AttachHandlers {
|
|
46
|
+
/** Output, scrollback replay first. */
|
|
47
|
+
onData(data: Buffer): void;
|
|
48
|
+
/** The session ended (at once, after the replay, when it already had). */
|
|
49
|
+
onExit?(exitCode: number | undefined): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One attachment; a session has any number, each gets all the output. */
|
|
53
|
+
export interface Attachment {
|
|
54
|
+
/** Settles once the replay has been delivered and live output follows. */
|
|
55
|
+
readonly ready: Promise<SessionInfo>;
|
|
56
|
+
/** Stop receiving output. The session keeps running. */
|
|
57
|
+
detach(): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type SessionSignal = "INT" | "TERM" | "KILL";
|
|
61
|
+
export const SESSION_SIGNALS: readonly SessionSignal[] = [
|
|
62
|
+
"INT",
|
|
63
|
+
"TERM",
|
|
64
|
+
"KILL",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export interface SessionBackend {
|
|
68
|
+
/** "den-pty", "tmux". */
|
|
69
|
+
readonly id: string;
|
|
70
|
+
start(spec: SessionSpec): Promise<SessionRef>;
|
|
71
|
+
/** Survives a web server restart: the sessions live elsewhere. */
|
|
72
|
+
list(): Promise<SessionInfo[]>;
|
|
73
|
+
attach(ref: SessionRef, handlers: AttachHandlers): Attachment;
|
|
74
|
+
write(ref: SessionRef, bytes: Uint8Array): void;
|
|
75
|
+
resize(ref: SessionRef, cols: number, rows: number): void;
|
|
76
|
+
signal(ref: SessionRef, signal: SessionSignal): void;
|
|
77
|
+
/** The newest `maxBytes` of output, for a replay on attach. */
|
|
78
|
+
scrollback(ref: SessionRef, maxBytes: number): Promise<Uint8Array>;
|
|
79
|
+
/** A command a person can run to attach outside Den (tmux), or null. */
|
|
80
|
+
externalAttach?(ref: SessionRef): string | null;
|
|
81
|
+
}
|