@songsid/agend 2.1.4 → 2.1.5-beta.10

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.
Files changed (82) hide show
  1. package/dist/backend/claude-code.js +2 -2
  2. package/dist/backend/claude-code.js.map +1 -1
  3. package/dist/backend/types.d.ts +22 -0
  4. package/dist/backend/types.js +23 -1
  5. package/dist/backend/types.js.map +1 -1
  6. package/dist/channel/adapters/discord.d.ts +2 -0
  7. package/dist/channel/adapters/discord.js +25 -3
  8. package/dist/channel/adapters/discord.js.map +1 -1
  9. package/dist/channel/adapters/telegram.d.ts +5 -0
  10. package/dist/channel/adapters/telegram.js +11 -0
  11. package/dist/channel/adapters/telegram.js.map +1 -1
  12. package/dist/channel/types.d.ts +7 -0
  13. package/dist/cli.js +37 -37
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.js +38 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/daemon.d.ts +38 -0
  18. package/dist/daemon.js +95 -43
  19. package/dist/daemon.js.map +1 -1
  20. package/dist/fleet-context.d.ts +5 -0
  21. package/dist/fleet-manager.d.ts +126 -0
  22. package/dist/fleet-manager.js +688 -95
  23. package/dist/fleet-manager.js.map +1 -1
  24. package/dist/full-restart.d.ts +20 -0
  25. package/dist/full-restart.js +39 -0
  26. package/dist/full-restart.js.map +1 -0
  27. package/dist/locale.js +72 -2
  28. package/dist/locale.js.map +1 -1
  29. package/dist/login-controller.d.ts +127 -0
  30. package/dist/login-controller.js +450 -0
  31. package/dist/login-controller.js.map +1 -0
  32. package/dist/login-flows.d.ts +41 -3
  33. package/dist/login-flows.js +37 -5
  34. package/dist/login-flows.js.map +1 -1
  35. package/dist/login-manager.d.ts +12 -1
  36. package/dist/login-manager.js +64 -7
  37. package/dist/login-manager.js.map +1 -1
  38. package/dist/login-window-lock.d.ts +26 -0
  39. package/dist/login-window-lock.js +64 -0
  40. package/dist/login-window-lock.js.map +1 -0
  41. package/dist/restart-progress.d.ts +35 -5
  42. package/dist/restart-progress.js +195 -34
  43. package/dist/restart-progress.js.map +1 -1
  44. package/dist/service-restart-selection.d.ts +27 -0
  45. package/dist/service-restart-selection.js +25 -0
  46. package/dist/service-restart-selection.js.map +1 -0
  47. package/dist/tmux-manager.d.ts +19 -0
  48. package/dist/tmux-manager.js +79 -12
  49. package/dist/tmux-manager.js.map +1 -1
  50. package/dist/topic-commands.js +23 -7
  51. package/dist/topic-commands.js.map +1 -1
  52. package/dist/types.d.ts +18 -0
  53. package/dist/ui/web-terminal/terminal.css +17 -0
  54. package/dist/ui/web-terminal/terminal.html +44 -0
  55. package/dist/ui/web-terminal/terminal.js +148 -0
  56. package/dist/ui/web-terminal/vendor/LICENSE.addon-fit +19 -0
  57. package/dist/ui/web-terminal/vendor/LICENSE.addon-web-links +19 -0
  58. package/dist/ui/web-terminal/vendor/LICENSE.xterm +21 -0
  59. package/dist/ui/web-terminal/vendor/VENDORED.md +22 -0
  60. package/dist/ui/web-terminal/vendor/addon-fit.js +1 -0
  61. package/dist/ui/web-terminal/vendor/addon-web-links.js +1 -0
  62. package/dist/ui/web-terminal/vendor/xterm.css +285 -0
  63. package/dist/ui/web-terminal/vendor/xterm.js +1 -0
  64. package/dist/update-marker.d.ts +11 -1
  65. package/dist/update-marker.js +19 -4
  66. package/dist/update-marker.js.map +1 -1
  67. package/dist/update-progress.d.ts +1 -1
  68. package/dist/update-progress.js +12 -0
  69. package/dist/update-progress.js.map +1 -1
  70. package/dist/usage/providers.d.ts +39 -0
  71. package/dist/usage/providers.js +69 -3
  72. package/dist/usage/providers.js.map +1 -1
  73. package/dist/web-terminal-http.d.ts +61 -0
  74. package/dist/web-terminal-http.js +422 -0
  75. package/dist/web-terminal-http.js.map +1 -0
  76. package/dist/web-terminal.d.ts +336 -0
  77. package/dist/web-terminal.js +888 -0
  78. package/dist/web-terminal.js.map +1 -0
  79. package/dist/ws-server.d.ts +69 -0
  80. package/dist/ws-server.js +392 -0
  81. package/dist/ws-server.js.map +1 -0
  82. package/package.json +1 -1
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Web terminal session: one command, one tmux pane, one browser, a few minutes.
3
+ *
4
+ * This is the core of remote `/login` and `/install-cli` (v2.1.5). The fleet
5
+ * starts exactly one command inside a *dedicated* tmux server and hands the
6
+ * admin a browser terminal onto that single pane. Nothing here interprets the
7
+ * CLI's screens or presses keys on the user's behalf — the human is the TUI's
8
+ * interpreter, so every CLI's every login flow works without per-CLI modelling.
9
+ *
10
+ * Security shape (design doc §3):
11
+ * - scope: the browser never gets a tmux client. Input goes through
12
+ * `send-keys -H` to this one pane; the pane runs `sh -c "<command>"` and
13
+ * dies when the command exits. No shell is reachable.
14
+ * - token gate: the URL carries no secret (sid is a path, not a credential).
15
+ * A separate one-time access token — delivered over the authenticated chat
16
+ * channel — is verified in constant time; three failures destroy the
17
+ * session. Success yields a session-bound cookie.
18
+ * - TTL: the session ends when the process exits, when the TTL lapses, on
19
+ * lockout, or on cancel. Ending always kills the tmux server.
20
+ * - observation: the fleet reads the pane (capture-pane) to post the device
21
+ * URL/code into chat and to judge success/failure — read-only.
22
+ *
23
+ * Output streaming uses `pipe-pane` into a FIFO we hold open O_RDWR (never
24
+ * EOF, nothing on disk). The pane is created running a placeholder and the
25
+ * real command is started with `respawn-pane -k` *after* the pipe is attached,
26
+ * so the very first bytes are captured (verified live: new-session + pipe-pane
27
+ * loses the first line; placeholder + respawn does not).
28
+ */
29
+ import { EventEmitter } from "node:events";
30
+ export interface WebTerminalObserve {
31
+ /** Overrides the generic https matcher when the CLI prints several URLs. */
32
+ urlPattern?: RegExp;
33
+ /** One-time device code shown next to the URL. First capture group wins. */
34
+ codePattern?: RegExp;
35
+ /** Pane text that proves the command achieved its purpose (login done). */
36
+ successPattern?: RegExp;
37
+ /** Known failure strings → human wording + suggested next step. */
38
+ failures?: Array<{
39
+ pattern: RegExp;
40
+ message: string;
41
+ suggest?: "relogin" | "check-args" | "retry";
42
+ }>;
43
+ }
44
+ export interface WebTerminalSpec {
45
+ kind: "login" | "install";
46
+ backend: string;
47
+ /** The one shell command the pane will run (`sh -c`). */
48
+ command: string;
49
+ cwd: string;
50
+ ttlMs: number;
51
+ cols?: number;
52
+ rows?: number;
53
+ observe?: WebTerminalObserve;
54
+ requester: {
55
+ adapterId: string;
56
+ userId: string;
57
+ chatId: string;
58
+ threadId?: string;
59
+ };
60
+ }
61
+ export type WebTerminalEndReason = "exit" | "ttl" | "cancel" | "token_lockout" | "error";
62
+ export interface WebTerminalResult {
63
+ ok: boolean;
64
+ reason: WebTerminalEndReason;
65
+ exitCode?: number;
66
+ /** Last non-empty pane lines (evidence), or the failure mapping's message. */
67
+ detail: string;
68
+ suggest?: "relogin" | "check-args" | "retry";
69
+ /** The dedicated tmux server could NOT be confirmed dead — operator attention needed. */
70
+ cleanupFailed?: boolean;
71
+ }
72
+ export interface WebTerminalEvents {
73
+ /** Device URL (+ code) appeared in the pane — post to chat (spoiler). Once per distinct URL. */
74
+ onHint?(url: string, code: string | null): void | Promise<void>;
75
+ /** Terminal state, exactly once. */
76
+ onDone(result: WebTerminalResult): void | Promise<void>;
77
+ /** Audit trail (eventLog). Never receives the token or cookie. */
78
+ onAudit?(event: string, fields: Record<string, unknown>): void;
79
+ }
80
+ /** A browser attached over WebSocket, as the session sees it. */
81
+ export interface TerminalClient {
82
+ send(data: Buffer | string): void;
83
+ close(code: number, reason: string): void;
84
+ }
85
+ export interface TerminalLogger {
86
+ debug(obj: unknown, msg?: string): void;
87
+ info(obj: unknown, msg?: string): void;
88
+ warn(obj: unknown, msg?: string): void;
89
+ }
90
+ /** The tmux operations a session needs — injectable for unit tests. */
91
+ export interface TerminalBackend {
92
+ start(opts: {
93
+ socket: string;
94
+ command: string;
95
+ cwd: string;
96
+ cols: number;
97
+ rows: number;
98
+ onOutput: (chunk: Buffer) => void;
99
+ /** Aborted by finish(): no stage may leave a resource behind once this fires. */
100
+ signal?: AbortSignal;
101
+ }): Promise<void>;
102
+ sendInput(socket: string, bytes: Buffer): Promise<void>;
103
+ resize(socket: string, cols: number, rows: number): Promise<void>;
104
+ /** Plain-text pane content (joined wrapped lines), for observation and evidence. */
105
+ capture(socket: string): Promise<string>;
106
+ paneStatus(socket: string): Promise<{
107
+ alive: boolean;
108
+ exitCode?: number;
109
+ } | null>;
110
+ /** Resolves only when the server is confirmed gone; rejects when it may still be alive. */
111
+ kill(socket: string): Promise<void>;
112
+ }
113
+ export declare const ACCESS_TOKEN_LENGTH = 20;
114
+ export declare const MAX_TOKEN_ATTEMPTS = 3;
115
+ export declare const MAX_TTL_MS: number;
116
+ export declare const DEFAULT_COLS = 120;
117
+ export declare const DEFAULT_ROWS = 36;
118
+ export declare const MIN_COLS = 20, MAX_COLS = 250, MIN_ROWS = 5, MAX_ROWS = 100;
119
+ /** Bytes of browser input allowed to wait for tmux before the session is ended as wedged (B4). */
120
+ export declare const MAX_PENDING_INPUT_BYTES: number;
121
+ /** Queued tmux operations allowed to wait (input batches + at most one resize); more means tmux is stuck. */
122
+ export declare const MAX_PENDING_JOBS = 64;
123
+ /** Consecutive failed pane probes before the session is ended as unreachable. */
124
+ export declare const MAX_PROBE_FAILURES = 3;
125
+ /** 20 base32 chars = 100 bits of entropy (13 random bytes, the last 4 bits truncated). */
126
+ export declare function generateAccessToken(bytes?: Buffer): string;
127
+ /** Evidence for a failure report: the last `n` non-empty lines, bounded. */
128
+ export declare function nonEmptyTail(text: string, n?: number, maxLen?: number): string;
129
+ export declare class WebTerminalSession extends EventEmitter {
130
+ readonly spec: WebTerminalSpec;
131
+ private readonly events;
132
+ private readonly backend;
133
+ private readonly logger;
134
+ private readonly now;
135
+ readonly sid: string;
136
+ readonly socketName: string;
137
+ readonly createdAt: number;
138
+ expiresAt: number;
139
+ state: "created" | "running" | "finished";
140
+ private accessToken;
141
+ private tokenAttempts;
142
+ private cookieValue;
143
+ private readonly replay;
144
+ private replayBytes;
145
+ private replayTruncated;
146
+ private client;
147
+ private ttlTimer;
148
+ private pollTimer;
149
+ private polling;
150
+ private finishing;
151
+ /** The backend.start() in flight, so finish() can wait for it to settle before its one confirmed kill. */
152
+ private startInFlight;
153
+ /** Aborted by finish(): the backend checks it after every stage and tears down anything it created. */
154
+ private readonly startAbort;
155
+ private sentUrls;
156
+ private successSeenAt;
157
+ /** Consecutive polls where tmux could not even be asked — a vanished server must not idle until TTL. */
158
+ private probeFailures;
159
+ /** Geometry most recently requested by the browser (queued, frozen or applied) — the only dedupe key. */
160
+ private lastRequested;
161
+ /** Single FIFO for input + resize: browser order is pane order (B4). */
162
+ private ioQueue;
163
+ private pendingInputBytes;
164
+ private pendingJobCount;
165
+ /** Input bytes not yet handed to a running job: consecutive frames coalesce into one tmux paste. */
166
+ private pendingBatch;
167
+ /**
168
+ * The resize job at the TAIL of the queue, still open for coalescing. Only
169
+ * while no input has been queued after it may a newer resize update it;
170
+ * input freezes it (a resize is a barrier and must stay in its FIFO slot).
171
+ */
172
+ private tailResize;
173
+ private cols;
174
+ private rows;
175
+ constructor(spec: WebTerminalSpec, events: WebTerminalEvents, backend: TerminalBackend, logger: TerminalLogger, now?: () => number);
176
+ /** The one-time access token, readable only until it is redeemed or the session ends. */
177
+ peekAccessToken(): string | null;
178
+ get ttlRemainingMs(): number;
179
+ start(): Promise<void>;
180
+ /**
181
+ * Redeem the one-time access token. Constant-time compare; three failures
182
+ * destroy the session (URL + attempts = suspected leak). Success returns the
183
+ * cookie value the HTTP layer sets; the token is gone from memory afterwards.
184
+ */
185
+ redeemToken(candidate: string): {
186
+ result: "ok";
187
+ cookie: string;
188
+ } | {
189
+ result: "bad";
190
+ remaining: number;
191
+ } | {
192
+ result: "used" | "locked" | "finished";
193
+ };
194
+ checkCookie(value: string | undefined): boolean;
195
+ /** Attach the (single) browser; replays buffered output first. Returns a detach function. */
196
+ attachClient(client: TerminalClient): () => void;
197
+ /** Bytes queued for the pane but not yet delivered. */
198
+ get pendingInput(): number;
199
+ /** Queued tmux operations not yet started (tests: must stay small under any input pattern). */
200
+ get pendingJobs(): number;
201
+ /**
202
+ * Queue browser input for the pane. Strictly ordered with resize.
203
+ * Consecutive input frames coalesce into ONE paste (a resize is a barrier),
204
+ * so the number of tmux operations is bounded by the number of barriers,
205
+ * not by the number of keystrokes. Returns false when the session is not
206
+ * running. A backlog beyond MAX_PENDING_INPUT_BYTES / MAX_PENDING_JOBS
207
+ * means tmux is wedged: the session ENDS (fail closed) rather than letting
208
+ * queued keystrokes reach a credential prompt unattended.
209
+ */
210
+ input(bytes: Buffer): boolean;
211
+ resize(cols: number, rows: number): void;
212
+ /** Everything queued before the returned promise settles has reached tmux (tests). */
213
+ drain(): Promise<void>;
214
+ private enqueue;
215
+ cancel(detail?: string): Promise<void>;
216
+ private onOutput;
217
+ private schedulePoll;
218
+ /** Exposed for tests; the timer calls this every second. */
219
+ poll(): Promise<void>;
220
+ private observe;
221
+ private finishFromExit;
222
+ private finish;
223
+ private audit;
224
+ }
225
+ /**
226
+ * Drives a dedicated tmux server (`-L <socket>`, `-f /dev/null` so the user's
227
+ * tmux.conf cannot alter behaviour). Output: pipe-pane → FIFO held O_RDWR.
228
+ */
229
+ export declare class TmuxTerminalBackend implements TerminalBackend {
230
+ private readonly tmuxBin;
231
+ private readonly streams;
232
+ /**
233
+ * Identity of each dedicated server, captured right after new-session: the
234
+ * PID plus an immutable process-generation fingerprint (Linux /proc start
235
+ * time). There is no weaker fallback: on platforms without /proc no
236
+ * identity is recorded and no signal is ever sent. A bare PID may be reused
237
+ * by the OS once the server dies outside our control; a signal must NEVER
238
+ * be sent unless the fingerprint still matches — otherwise the "one
239
+ * command" scope would be violated against an unrelated process.
240
+ */
241
+ private readonly servers;
242
+ private readonly probe;
243
+ constructor(tmuxBin?: string, opts?: {
244
+ probeProcess?: (pid: number) => ProcessProbe;
245
+ });
246
+ /** Test seam: the recorded server identity, if a signal fallback was registered. */
247
+ serverRecordForTests(socket: string): {
248
+ pid: number;
249
+ identity: string;
250
+ } | undefined;
251
+ /**
252
+ * Run one tmux command. Errors are re-thrown SANITIZED: operation name,
253
+ * socket and exit code only — never the argv, which for input would be the
254
+ * user's keystrokes (B2), and never tmux's stderr, which echoes the command.
255
+ */
256
+ private tmux;
257
+ start(opts: {
258
+ socket: string;
259
+ command: string;
260
+ cwd: string;
261
+ cols: number;
262
+ rows: number;
263
+ onOutput: (chunk: Buffer) => void;
264
+ signal?: AbortSignal;
265
+ }): Promise<void>;
266
+ /**
267
+ * Deliver bytes to the pane WITHOUT putting typed text in any argv (B2).
268
+ *
269
+ * Two transports, chosen per run of bytes (see segmentInput):
270
+ * - text runs (anything a user could be typing as a secret) go to tmux
271
+ * over stdin into a named buffer and are pasted with `paste-buffer -r`
272
+ * (raw: bytes unchanged), `-d` deleting the buffer;
273
+ * - control runs (0x00–0x1f, 0x7f and complete ESC sequences: arrows,
274
+ * Enter, Tab, Ctrl-C…) go through `send-keys -H`. They carry no
275
+ * secret, and this is the only path on which the pane's tty performs
276
+ * signal handling: a pasted 0x03 is echoed but does NOT raise SIGINT
277
+ * (verified live), a sent one does.
278
+ */
279
+ sendInput(socket: string, bytes: Buffer): Promise<void>;
280
+ resize(socket: string, cols: number, rows: number): Promise<void>;
281
+ capture(socket: string): Promise<string>;
282
+ paneStatus(socket: string): Promise<{
283
+ alive: boolean;
284
+ exitCode?: number;
285
+ } | null>;
286
+ /**
287
+ * Tri-state liveness of the dedicated server. "dead" is asserted only on
288
+ * POSITIVE evidence of absence (tmux's own no-server answer, and — when a
289
+ * PID is known — the process gone); a probe that could not execute
290
+ * (spawn failure, timeout, unexpected exit) is "unknown", never "dead".
291
+ * stderr is inspected in memory only and never logged.
292
+ */
293
+ serverState(socket: string): Promise<"alive" | "dead" | "unknown">;
294
+ /**
295
+ * Kill the dedicated server and CONFIRM it is gone (B2). `kill-server` is
296
+ * tried twice; if the server is not positively dead, the PID captured at
297
+ * start is sent SIGTERM then SIGKILL. The FINAL probe decides: resolves
298
+ * only on "dead"; "alive" and "unknown" both reject so the caller reports
299
+ * a cleanup failure instead of claiming the boundary held. "Already gone"
300
+ * (positively) is success.
301
+ */
302
+ kill(socket: string): Promise<void>;
303
+ /** Test seam: adopt a server identity as if captured at start. */
304
+ rememberServerForTests(socket: string, pid: number, identity: string): void;
305
+ }
306
+ export type ProcessProbe = {
307
+ kind: "identified";
308
+ identity: string;
309
+ comm?: string;
310
+ } | {
311
+ kind: "gone";
312
+ } | {
313
+ kind: "unknown";
314
+ };
315
+ /**
316
+ * Tri-state process probe. "gone" requires positive ESRCH evidence. The
317
+ * identity is the process start time in clock ticks since boot (Linux
318
+ * /proc/<pid>/stat field 22) and NOTHING else: it is fixed for the life of
319
+ * the process, so a mismatch is proof of PID reuse. The command name is
320
+ * returned separately for diagnostics only — a live process can rename
321
+ * itself (prctl / /proc/self/comm), so it must never take part in equality.
322
+ * On platforms without /proc there is NO fingerprint: the probe can only say
323
+ * gone/unknown, and the backend registers no signal fallback.
324
+ */
325
+ export declare function probeProcess(pid: number): ProcessProbe;
326
+ /**
327
+ * Split browser input into runs: "control" (C0 bytes, DEL, and complete ESC
328
+ * sequences — never secrets) vs "text" (everything else — possibly a
329
+ * password). Control runs may travel in argv; text runs must not.
330
+ */
331
+ export declare function segmentInput(bytes: Buffer): Array<{
332
+ kind: "control" | "text";
333
+ bytes: Buffer;
334
+ }>;
335
+ /** Single-quote for `sh`: the only quoting that survives any content. */
336
+ export declare function shellQuote(s: string): string;