@hwip/cli 0.0.1 → 0.1.1
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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a session WAS, kept so it can be started again.
|
|
3
|
+
*
|
|
4
|
+
* tmux is the only record of a live session, and it forgets one the moment it
|
|
5
|
+
* ends — which is exactly when the phone wants it back. So the listener writes
|
|
6
|
+
* down what it saw while the session was alive: where it ran and which agent it
|
|
7
|
+
* ran, keyed by the tmux name the phone already addresses it by **and** the
|
|
8
|
+
* agent that ran under it — tmux hands the same name out again, and one row per
|
|
9
|
+
* name meant the next occupant erased the last one's record (`runKey`).
|
|
10
|
+
*
|
|
11
|
+
* This file is the whitelist that makes remote resume safe. A resume request
|
|
12
|
+
* carries a session NAME and nothing else; the directory and the binary come
|
|
13
|
+
* from here, from what this machine observed itself. Nothing a phone (or a
|
|
14
|
+
* relay posing as one) sends can point the daemon at another directory or
|
|
15
|
+
* another program.
|
|
16
|
+
*/
|
|
17
|
+
export interface KnownSession {
|
|
18
|
+
/** tmux session name — the key the phone addresses. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Pane cwd while it was alive. Where a resume starts the agent again. */
|
|
21
|
+
cwd: string;
|
|
22
|
+
/** RemoteAgent `kind` (claude/codex/…), resolved to a binary at resume time. */
|
|
23
|
+
agentKind: string;
|
|
24
|
+
project?: string;
|
|
25
|
+
label?: string;
|
|
26
|
+
/** Last time this machine saw the session running. */
|
|
27
|
+
lastSeenAt: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Ceiling on remembered sessions. tmux names come from a small reused pool
|
|
31
|
+
* (`hwip-<project>`, `-2`, …), so this holds far more distinct projects than it
|
|
32
|
+
* looks; the cap only stops the file from growing without bound on a machine
|
|
33
|
+
* that has run agents for years.
|
|
34
|
+
*/
|
|
35
|
+
export declare const MAX_KNOWN_SESSIONS = 100;
|
|
36
|
+
/** Forgotten after this long unseen — a directory from months ago is more
|
|
37
|
+
* likely to have moved than to be what the user meant. */
|
|
38
|
+
export declare const KNOWN_SESSION_TTL_MS: number;
|
|
39
|
+
/**
|
|
40
|
+
* What makes two sightings the same remembered run: the tmux name AND the
|
|
41
|
+
* agent that was running under it.
|
|
42
|
+
*
|
|
43
|
+
* Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
|
|
44
|
+
* the same slot — the ended run left no row to resume and no row to read, and
|
|
45
|
+
* the past list silently answered with the newest occupant instead. tmux hands
|
|
46
|
+
* a name back out; the record of what ran under it is not the name's to lose.
|
|
47
|
+
*
|
|
48
|
+
* NUL rather than a printable separator: a project directory can be called
|
|
49
|
+
* almost anything, and a name holding the separator must not be able to forge
|
|
50
|
+
* another run's key.
|
|
51
|
+
*/
|
|
52
|
+
export declare const runKey: (name: string, agentKind: string) => string;
|
|
53
|
+
/** Sessions this machine has seen, newest first, expired ones dropped. */
|
|
54
|
+
export declare const knownSessions: (now?: number) => KnownSession[];
|
|
55
|
+
/** One remembered session, or null when this machine never saw that name.
|
|
56
|
+
* The newest run under that name — which is the one a bare resume means. */
|
|
57
|
+
export declare const recallSession: (name: string, now?: number) => KnownSession | null;
|
|
58
|
+
/**
|
|
59
|
+
* One specific run: the newest under this name that ran THIS agent.
|
|
60
|
+
*
|
|
61
|
+
* A tmux name is a slot several agents pass through, so "resume hwip-web" is
|
|
62
|
+
* ambiguous the moment the slot has held both pi and claude. The phone knows
|
|
63
|
+
* which row was tapped and says so; this is how that answer is honoured.
|
|
64
|
+
* Null when this machine never saw that agent under that name — the caller
|
|
65
|
+
* decides whether to fall back to the newest run or refuse.
|
|
66
|
+
*/
|
|
67
|
+
export declare const recallRun: (name: string, agentKind: string, now?: number) => KnownSession | null;
|
|
68
|
+
/**
|
|
69
|
+
* Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
|
|
70
|
+
* CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
|
|
71
|
+
* whitelist, written in place (not renamed over), so a second writer can leave the listener's reader a
|
|
72
|
+
* half-written file. Never called in the listener's own process, which stays the one writer.
|
|
73
|
+
*/
|
|
74
|
+
export declare const keepRegistryUntouched: () => void;
|
|
75
|
+
/**
|
|
76
|
+
* Write down the sessions running right now, replacing what was known about
|
|
77
|
+
* each. Called from the inventory sweep, so the record follows a session that
|
|
78
|
+
* moves directory or changes agent rather than pinning its first sighting.
|
|
79
|
+
*
|
|
80
|
+
* A session with no readable cwd is skipped rather than remembered without
|
|
81
|
+
* one: an entry that cannot say where to start the agent is not a resume
|
|
82
|
+
* target, only a row that would fail when tapped.
|
|
83
|
+
*/
|
|
84
|
+
export declare const rememberSessions: (live: Array<{
|
|
85
|
+
name: string;
|
|
86
|
+
cwd: string | null;
|
|
87
|
+
agentKind: string;
|
|
88
|
+
project?: string | null;
|
|
89
|
+
label?: string | null;
|
|
90
|
+
}>, now?: number) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Forget one session, by name. Returns false when this machine never knew it.
|
|
93
|
+
*
|
|
94
|
+
* This is what deleting a past session means on the machine that ran it: the
|
|
95
|
+
* entry leaves the file, so the phone stops being offered the session AND —
|
|
96
|
+
* since this file is the resume whitelist — stops being able to start it.
|
|
97
|
+
* That is the intended pair, not a side effect.
|
|
98
|
+
*
|
|
99
|
+
* It stays forgotten: `rememberSessions` only writes down sessions that are
|
|
100
|
+
* running, so nothing re-adds an entry for a session that has ended. Running
|
|
101
|
+
* that name again is what brings it back, which is also the only way back.
|
|
102
|
+
*/
|
|
103
|
+
export type ForgetOutcome =
|
|
104
|
+
/** This machine never had a record under that name. */
|
|
105
|
+
'unknown'
|
|
106
|
+
/** Registry entry and chat scrollback both gone. */
|
|
107
|
+
| 'forgotten'
|
|
108
|
+
/** Registry entry gone, but the scrollback file would not delete. */
|
|
109
|
+
| 'scrollback_kept';
|
|
110
|
+
export declare const forgetSession: (name: string, agentKind?: string) => ForgetOutcome;
|
|
111
|
+
/**
|
|
112
|
+
* What the caller was able to find out about a name. `unknown` is not a
|
|
113
|
+
* synonym for ended: it is the terminal failing to answer at all — no tmux to
|
|
114
|
+
* run, or no server behind the socket it resolved — and a sweep that deletes on
|
|
115
|
+
* `ended` must not delete on that. Deciding this is the caller's job; this
|
|
116
|
+
* module owns the file, not the terminal.
|
|
117
|
+
*/
|
|
118
|
+
export type SessionLiveness = 'running' | 'ended' | 'unknown';
|
|
119
|
+
/** What one sweep did. `scrollbackKept` is a subset of `forgotten`: the row is
|
|
120
|
+
* gone either way, and these are the ones whose chat history stayed behind. */
|
|
121
|
+
export type EndedSweep = {
|
|
122
|
+
forgotten: string[];
|
|
123
|
+
scrollbackKept: string[];
|
|
124
|
+
kept: string[];
|
|
125
|
+
unreachable: string[];
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Forget every remembered name that no longer runs, and report each side.
|
|
129
|
+
*
|
|
130
|
+
* The unit is the NAME, not the run: `forgetSession(name)` takes every run of
|
|
131
|
+
* the name, and a name tmux still holds is one the next inventory sweep writes
|
|
132
|
+
* straight back — so a name with a live run is left alone, its ended runs
|
|
133
|
+
* included. Each name is judged, deleted and reported once, however many runs
|
|
134
|
+
* sit under it.
|
|
135
|
+
*/
|
|
136
|
+
export declare const forgetEnded: (liveness: (name: string) => SessionLiveness, now?: number) => EndedSweep;
|
|
137
|
+
/** Whether the registry knows this name — the resume whitelist check. */
|
|
138
|
+
export declare const isKnownSession: (name: string, now?: number) => boolean;
|
|
139
|
+
/** Test seam: the file this module reads and writes. */
|
|
140
|
+
export declare const knownSessionsPath: () => string;
|
|
141
|
+
/** True when the recorded directory still exists to start an agent in. */
|
|
142
|
+
export declare const sessionDirectoryExists: (entry: KnownSession) => boolean;
|
|
143
|
+
//# sourceMappingURL=session-registry.d.ts.map
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a session WAS, kept so it can be started again.
|
|
4
|
+
*
|
|
5
|
+
* tmux is the only record of a live session, and it forgets one the moment it
|
|
6
|
+
* ends — which is exactly when the phone wants it back. So the listener writes
|
|
7
|
+
* down what it saw while the session was alive: where it ran and which agent it
|
|
8
|
+
* ran, keyed by the tmux name the phone already addresses it by **and** the
|
|
9
|
+
* agent that ran under it — tmux hands the same name out again, and one row per
|
|
10
|
+
* name meant the next occupant erased the last one's record (`runKey`).
|
|
11
|
+
*
|
|
12
|
+
* This file is the whitelist that makes remote resume safe. A resume request
|
|
13
|
+
* carries a session NAME and nothing else; the directory and the binary come
|
|
14
|
+
* from here, from what this machine observed itself. Nothing a phone (or a
|
|
15
|
+
* relay posing as one) sends can point the daemon at another directory or
|
|
16
|
+
* another program.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.recallRun = exports.recallSession = exports.knownSessions = exports.runKey = exports.KNOWN_SESSION_TTL_MS = exports.MAX_KNOWN_SESSIONS = void 0;
|
|
20
|
+
const fs_1 = require("fs");
|
|
21
|
+
const path_1 = require("path");
|
|
22
|
+
const turn_ring_js_1 = require("./turn-ring.js");
|
|
23
|
+
const gate_js_1 = require("./gate.js");
|
|
24
|
+
const private_files_js_1 = require("./private-files.js");
|
|
25
|
+
/**
|
|
26
|
+
* Ceiling on remembered sessions. tmux names come from a small reused pool
|
|
27
|
+
* (`hwip-<project>`, `-2`, …), so this holds far more distinct projects than it
|
|
28
|
+
* looks; the cap only stops the file from growing without bound on a machine
|
|
29
|
+
* that has run agents for years.
|
|
30
|
+
*/
|
|
31
|
+
exports.MAX_KNOWN_SESSIONS = 100;
|
|
32
|
+
/** Forgotten after this long unseen — a directory from months ago is more
|
|
33
|
+
* likely to have moved than to be what the user meant. */
|
|
34
|
+
exports.KNOWN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
35
|
+
const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'known-sessions.json');
|
|
36
|
+
const readAll = () => {
|
|
37
|
+
try {
|
|
38
|
+
const raw = (0, fs_1.readFileSync)(registryPath(), 'utf-8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!Array.isArray(parsed))
|
|
41
|
+
return [];
|
|
42
|
+
// Written by this process, but a half-written or hand-edited file must
|
|
43
|
+
// not take the listener down — keep the rows that still make sense.
|
|
44
|
+
return parsed.filter((e) => !!e &&
|
|
45
|
+
typeof e === 'object' &&
|
|
46
|
+
typeof e.name === 'string' &&
|
|
47
|
+
typeof e.cwd === 'string' &&
|
|
48
|
+
typeof e.agentKind === 'string' &&
|
|
49
|
+
typeof e.lastSeenAt === 'string');
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const writeAll = (entries) => {
|
|
56
|
+
const path = registryPath();
|
|
57
|
+
try {
|
|
58
|
+
(0, private_files_js_1.mkdirHandedBack)((0, path_1.dirname)(path), 0o700);
|
|
59
|
+
// 0600: it names the user's project directories, which is not something
|
|
60
|
+
// every account on the machine needs to read.
|
|
61
|
+
(0, fs_1.writeFileSync)(path, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
62
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// A registry that cannot be written costs the resume affordance, not
|
|
66
|
+
// the daemon — every other path keeps working.
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* What makes two sightings the same remembered run: the tmux name AND the
|
|
71
|
+
* agent that was running under it.
|
|
72
|
+
*
|
|
73
|
+
* Keyed by name alone, a `hwip cc` in a project erased whatever pi had done in
|
|
74
|
+
* the same slot — the ended run left no row to resume and no row to read, and
|
|
75
|
+
* the past list silently answered with the newest occupant instead. tmux hands
|
|
76
|
+
* a name back out; the record of what ran under it is not the name's to lose.
|
|
77
|
+
*
|
|
78
|
+
* NUL rather than a printable separator: a project directory can be called
|
|
79
|
+
* almost anything, and a name holding the separator must not be able to forge
|
|
80
|
+
* another run's key.
|
|
81
|
+
*/
|
|
82
|
+
const runKey = (name, agentKind) => `${name}\u0000${agentKind}`;
|
|
83
|
+
exports.runKey = runKey;
|
|
84
|
+
/** Sessions this machine has seen, newest first, expired ones dropped. */
|
|
85
|
+
const knownSessions = (now = Date.now()) => readAll()
|
|
86
|
+
.filter((e) => now - Date.parse(e.lastSeenAt) < exports.KNOWN_SESSION_TTL_MS)
|
|
87
|
+
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
|
|
88
|
+
exports.knownSessions = knownSessions;
|
|
89
|
+
/** One remembered session, or null when this machine never saw that name.
|
|
90
|
+
* The newest run under that name — which is the one a bare resume means. */
|
|
91
|
+
const recallSession = (name, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name) ?? null;
|
|
92
|
+
exports.recallSession = recallSession;
|
|
93
|
+
/**
|
|
94
|
+
* One specific run: the newest under this name that ran THIS agent.
|
|
95
|
+
*
|
|
96
|
+
* A tmux name is a slot several agents pass through, so "resume hwip-web" is
|
|
97
|
+
* ambiguous the moment the slot has held both pi and claude. The phone knows
|
|
98
|
+
* which row was tapped and says so; this is how that answer is honoured.
|
|
99
|
+
* Null when this machine never saw that agent under that name — the caller
|
|
100
|
+
* decides whether to fall back to the newest run or refuse.
|
|
101
|
+
*/
|
|
102
|
+
const recallRun = (name, agentKind, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind) ?? null;
|
|
103
|
+
exports.recallRun = recallRun;
|
|
104
|
+
let readOnly = false;
|
|
105
|
+
/**
|
|
106
|
+
* Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
|
|
107
|
+
* CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
|
|
108
|
+
* whitelist, written in place (not renamed over), so a second writer can leave the listener's reader a
|
|
109
|
+
* half-written file. Never called in the listener's own process, which stays the one writer.
|
|
110
|
+
*/
|
|
111
|
+
const keepRegistryUntouched = () => {
|
|
112
|
+
readOnly = true;
|
|
113
|
+
};
|
|
114
|
+
exports.keepRegistryUntouched = keepRegistryUntouched;
|
|
115
|
+
/**
|
|
116
|
+
* Write down the sessions running right now, replacing what was known about
|
|
117
|
+
* each. Called from the inventory sweep, so the record follows a session that
|
|
118
|
+
* moves directory or changes agent rather than pinning its first sighting.
|
|
119
|
+
*
|
|
120
|
+
* A session with no readable cwd is skipped rather than remembered without
|
|
121
|
+
* one: an entry that cannot say where to start the agent is not a resume
|
|
122
|
+
* target, only a row that would fail when tapped.
|
|
123
|
+
*/
|
|
124
|
+
const rememberSessions = (live, now = Date.now()) => {
|
|
125
|
+
if (readOnly)
|
|
126
|
+
return;
|
|
127
|
+
const usable = live.filter((s) => !!s.name && !!s.cwd);
|
|
128
|
+
if (usable.length === 0)
|
|
129
|
+
return;
|
|
130
|
+
const seenAt = new Date(now).toISOString();
|
|
131
|
+
const byRun = new Map((0, exports.knownSessions)(now).map((e) => [(0, exports.runKey)(e.name, e.agentKind), e]));
|
|
132
|
+
for (const s of usable) {
|
|
133
|
+
byRun.set((0, exports.runKey)(s.name, s.agentKind), {
|
|
134
|
+
name: s.name,
|
|
135
|
+
cwd: s.cwd,
|
|
136
|
+
agentKind: s.agentKind,
|
|
137
|
+
...(s.project ? { project: s.project } : {}),
|
|
138
|
+
...(s.label ? { label: s.label } : {}),
|
|
139
|
+
lastSeenAt: seenAt,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const entries = [...byRun.values()]
|
|
143
|
+
.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
|
|
144
|
+
.slice(0, exports.MAX_KNOWN_SESSIONS);
|
|
145
|
+
writeAll(entries);
|
|
146
|
+
};
|
|
147
|
+
exports.rememberSessions = rememberSessions;
|
|
148
|
+
const forgetSession = (name, agentKind) => {
|
|
149
|
+
const entries = readAll();
|
|
150
|
+
// With a kind, only that run goes — the phone deletes the row it is looking
|
|
151
|
+
// at, and a name that held both pi and claude shows two rows. Without one
|
|
152
|
+
// (an older phone, which sends no kind), the name goes entirely: that is
|
|
153
|
+
// what the request meant when a name could only mean one run.
|
|
154
|
+
const kept = entries.filter((e) => e.name !== name || (agentKind ? e.agentKind !== agentKind : false));
|
|
155
|
+
if (kept.length === entries.length)
|
|
156
|
+
return 'unknown';
|
|
157
|
+
// The chat's scrollback for that session goes with it. Forgetting a session
|
|
158
|
+
// everywhere except the one file that holds a day of its prompts and tool
|
|
159
|
+
// targets is not forgetting it — and a failure to delete that file has to
|
|
160
|
+
// reach the person who asked, not stay in a swallowed catch.
|
|
161
|
+
//
|
|
162
|
+
// Except while another run still answers to the name: the scrollback is per
|
|
163
|
+
// NAME, so deleting the pi row would take the claude row's history with it.
|
|
164
|
+
// The record is gone either way; the file waits for the last run to go.
|
|
165
|
+
const nameStillHeld = kept.some((e) => e.name === name);
|
|
166
|
+
writeAll(kept);
|
|
167
|
+
if (nameStillHeld)
|
|
168
|
+
return 'forgotten';
|
|
169
|
+
return (0, turn_ring_js_1.removeTurnRing)(name) ? 'forgotten' : 'scrollback_kept';
|
|
170
|
+
};
|
|
171
|
+
exports.forgetSession = forgetSession;
|
|
172
|
+
/**
|
|
173
|
+
* Forget every remembered name that no longer runs, and report each side.
|
|
174
|
+
*
|
|
175
|
+
* The unit is the NAME, not the run: `forgetSession(name)` takes every run of
|
|
176
|
+
* the name, and a name tmux still holds is one the next inventory sweep writes
|
|
177
|
+
* straight back — so a name with a live run is left alone, its ended runs
|
|
178
|
+
* included. Each name is judged, deleted and reported once, however many runs
|
|
179
|
+
* sit under it.
|
|
180
|
+
*/
|
|
181
|
+
const forgetEnded = (liveness, now = Date.now()) => {
|
|
182
|
+
const names = [...new Set((0, exports.knownSessions)(now).map((e) => e.name))];
|
|
183
|
+
const sweep = { forgotten: [], scrollbackKept: [], kept: [], unreachable: [] };
|
|
184
|
+
for (const name of names) {
|
|
185
|
+
const state = liveness(name);
|
|
186
|
+
if (state === 'running')
|
|
187
|
+
sweep.kept.push(name);
|
|
188
|
+
else if (state === 'unknown')
|
|
189
|
+
sweep.unreachable.push(name);
|
|
190
|
+
else
|
|
191
|
+
sweep.forgotten.push(name);
|
|
192
|
+
}
|
|
193
|
+
if (sweep.forgotten.length === 0)
|
|
194
|
+
return sweep;
|
|
195
|
+
// One read and one write for the whole sweep. Calling `forgetSession` per
|
|
196
|
+
// name would rewrite the file once per name — N chances for the daemon's
|
|
197
|
+
// inventory sweep to write in between and put a deleted row back.
|
|
198
|
+
const doomed = new Set(sweep.forgotten);
|
|
199
|
+
writeAll(readAll().filter((e) => !doomed.has(e.name)));
|
|
200
|
+
// Every run of these names has gone, so — unlike `forgetSession`, which
|
|
201
|
+
// spares a ring another run still answers to — each ring goes with them.
|
|
202
|
+
for (const name of sweep.forgotten) {
|
|
203
|
+
if (!(0, turn_ring_js_1.removeTurnRing)(name))
|
|
204
|
+
sweep.scrollbackKept.push(name);
|
|
205
|
+
}
|
|
206
|
+
return sweep;
|
|
207
|
+
};
|
|
208
|
+
exports.forgetEnded = forgetEnded;
|
|
209
|
+
/** Whether the registry knows this name — the resume whitelist check. */
|
|
210
|
+
const isKnownSession = (name, now = Date.now()) => (0, exports.recallSession)(name, now) !== null;
|
|
211
|
+
exports.isKnownSession = isKnownSession;
|
|
212
|
+
/** Test seam: the file this module reads and writes. */
|
|
213
|
+
exports.knownSessionsPath = registryPath;
|
|
214
|
+
/** True when the recorded directory still exists to start an agent in. */
|
|
215
|
+
const sessionDirectoryExists = (entry) => (0, fs_1.existsSync)(entry.cwd);
|
|
216
|
+
exports.sessionDirectoryExists = sessionDirectoryExists;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `listener.sessions` report, cut to fit the one message size this socket
|
|
3
|
+
* can carry.
|
|
4
|
+
*
|
|
5
|
+
* API Gateway closes a WebSocket whose frame is over 32 KB (close code 1009;
|
|
6
|
+
* "WebSocket frame size", a quota that cannot be raised). `ws` sends a message
|
|
7
|
+
* as one frame, and the report grows by about 290 bytes a session, so a machine
|
|
8
|
+
* with around a hundred sessions would see its connection dropped on every
|
|
9
|
+
* report and the inventory would never arrive.
|
|
10
|
+
*
|
|
11
|
+
* A report that fits goes out exactly as it always did. A larger one goes as
|
|
12
|
+
* (the server's contract, `server/SERVER-API.md`):
|
|
13
|
+
*
|
|
14
|
+
* - parts of at most `SESSIONS_REPORT_MAX_BYTES`, each `listener.sessions`
|
|
15
|
+
* with `partial: true` and some of the sessions — the server upserts what
|
|
16
|
+
* it is given, by name, and acks each part. The first part also carries
|
|
17
|
+
* `knownSessions` (once is enough);
|
|
18
|
+
* - then `listener.sessions.names` with every session name in the report, so
|
|
19
|
+
* the server can drop the sessions that are no longer there — what a
|
|
20
|
+
* whole-list report does by replacing the list.
|
|
21
|
+
*
|
|
22
|
+
* The next message waits for the previous part's ack (`sendSessionsReport`):
|
|
23
|
+
* the server merges overlapping parts safely, but overlapping ones can fire a
|
|
24
|
+
* blocked-session push twice. Parts also say their place (`part` of `parts`,
|
|
25
|
+
* one `reportId`); the server ignores those, they are for the log.
|
|
26
|
+
*
|
|
27
|
+
* A server that predates parts reads each part as a whole report (the last
|
|
28
|
+
* part would win) and does not know `listener.sessions.names`: the inventory is
|
|
29
|
+
* wrong there, but the connection stays up, which one oversized message did not.
|
|
30
|
+
*/
|
|
31
|
+
/** Under API Gateway's 32 KB frame, with room for the envelope and a long session or two. */
|
|
32
|
+
export declare const SESSIONS_REPORT_MAX_BYTES: number;
|
|
33
|
+
export interface SessionsReportPlan {
|
|
34
|
+
/** The JSON messages to send, in order: one when the report fits, else the parts and then `names`. */
|
|
35
|
+
messages: string[];
|
|
36
|
+
/** Sessions in the report. */
|
|
37
|
+
sessionCount: number;
|
|
38
|
+
/** Bytes of the report sent as one message — what it would have been uncut. */
|
|
39
|
+
wholeBytes: number;
|
|
40
|
+
/** Sessions whose own JSON is bigger than a part can hold: sent alone, over the limit. */
|
|
41
|
+
oversized: string[];
|
|
42
|
+
/** The `names` message is bigger than a message can hold (about 900 sessions): sent anyway. */
|
|
43
|
+
namesOversized: boolean;
|
|
44
|
+
/** Identifies a report in parts; the server echoes it (and `part`) in each ack. Absent for one message. */
|
|
45
|
+
reportId?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare const planSessionsReport: (sessions: readonly {
|
|
48
|
+
name: string;
|
|
49
|
+
}[], knownSessions: readonly unknown[], maxBytes?: number, reportId?: () => string) => SessionsReportPlan;
|
|
50
|
+
/** How long a part waits for its ack before the report is given up (and tried again on the next poll). */
|
|
51
|
+
export declare const SESSIONS_ACK_TIMEOUT_MS = 10000;
|
|
52
|
+
/**
|
|
53
|
+
* Send a plan's messages in order. With parts, every message but the last (`names`) waits for its
|
|
54
|
+
* ack before the next goes — `awaitAck(part)` is asked for before the send, so a quick answer is not
|
|
55
|
+
* missed, and `names` (which removes whatever it does not list) only goes once every part landed.
|
|
56
|
+
* False when an ack did not come: the rest is not sent, and the caller treats the report as unsent.
|
|
57
|
+
*/
|
|
58
|
+
export declare const sendSessionsReport: (messages: readonly string[], send: (message: string) => void, awaitAck: (part: number) => Promise<boolean>) => Promise<boolean>;
|
|
59
|
+
//# sourceMappingURL=sessions-report.d.ts.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The `listener.sessions` report, cut to fit the one message size this socket
|
|
4
|
+
* can carry.
|
|
5
|
+
*
|
|
6
|
+
* API Gateway closes a WebSocket whose frame is over 32 KB (close code 1009;
|
|
7
|
+
* "WebSocket frame size", a quota that cannot be raised). `ws` sends a message
|
|
8
|
+
* as one frame, and the report grows by about 290 bytes a session, so a machine
|
|
9
|
+
* with around a hundred sessions would see its connection dropped on every
|
|
10
|
+
* report and the inventory would never arrive.
|
|
11
|
+
*
|
|
12
|
+
* A report that fits goes out exactly as it always did. A larger one goes as
|
|
13
|
+
* (the server's contract, `server/SERVER-API.md`):
|
|
14
|
+
*
|
|
15
|
+
* - parts of at most `SESSIONS_REPORT_MAX_BYTES`, each `listener.sessions`
|
|
16
|
+
* with `partial: true` and some of the sessions — the server upserts what
|
|
17
|
+
* it is given, by name, and acks each part. The first part also carries
|
|
18
|
+
* `knownSessions` (once is enough);
|
|
19
|
+
* - then `listener.sessions.names` with every session name in the report, so
|
|
20
|
+
* the server can drop the sessions that are no longer there — what a
|
|
21
|
+
* whole-list report does by replacing the list.
|
|
22
|
+
*
|
|
23
|
+
* The next message waits for the previous part's ack (`sendSessionsReport`):
|
|
24
|
+
* the server merges overlapping parts safely, but overlapping ones can fire a
|
|
25
|
+
* blocked-session push twice. Parts also say their place (`part` of `parts`,
|
|
26
|
+
* one `reportId`); the server ignores those, they are for the log.
|
|
27
|
+
*
|
|
28
|
+
* A server that predates parts reads each part as a whole report (the last
|
|
29
|
+
* part would win) and does not know `listener.sessions.names`: the inventory is
|
|
30
|
+
* wrong there, but the connection stays up, which one oversized message did not.
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.sendSessionsReport = exports.SESSIONS_ACK_TIMEOUT_MS = exports.planSessionsReport = exports.SESSIONS_REPORT_MAX_BYTES = void 0;
|
|
34
|
+
const node_crypto_1 = require("node:crypto");
|
|
35
|
+
/** Under API Gateway's 32 KB frame, with room for the envelope and a long session or two. */
|
|
36
|
+
exports.SESSIONS_REPORT_MAX_BYTES = 24 * 1024;
|
|
37
|
+
const bytes = (text) => Buffer.byteLength(text, 'utf-8');
|
|
38
|
+
const planSessionsReport = (sessions, knownSessions, maxBytes = exports.SESSIONS_REPORT_MAX_BYTES, reportId = node_crypto_1.randomUUID) => {
|
|
39
|
+
const whole = JSON.stringify({ type: 'listener.sessions', data: { sessions, knownSessions } });
|
|
40
|
+
const wholeBytes = bytes(whole);
|
|
41
|
+
if (wholeBytes <= maxBytes) {
|
|
42
|
+
return { messages: [whole], sessionCount: sessions.length, wholeBytes, oversized: [], namesOversized: false };
|
|
43
|
+
}
|
|
44
|
+
const id = reportId();
|
|
45
|
+
const knownBytes = bytes(JSON.stringify({ knownSessions }));
|
|
46
|
+
const envelopeBytes = bytes(JSON.stringify({
|
|
47
|
+
type: 'listener.sessions',
|
|
48
|
+
data: { sessions: [], partial: true, reportId: id, part: 999, parts: 999 },
|
|
49
|
+
}));
|
|
50
|
+
// Greedy: a session goes in the current part while it fits. The first part gives up room for
|
|
51
|
+
// `knownSessions`; no part gives up less than one session.
|
|
52
|
+
const groups = [];
|
|
53
|
+
const oversized = [];
|
|
54
|
+
let current = [];
|
|
55
|
+
let budget = Math.max(maxBytes - envelopeBytes - knownBytes, 0);
|
|
56
|
+
let used = 0;
|
|
57
|
+
for (const session of sessions) {
|
|
58
|
+
// +1 for the comma between sessions in the array.
|
|
59
|
+
const size = bytes(JSON.stringify(session)) + 1;
|
|
60
|
+
if (current.length > 0 && used + size > budget) {
|
|
61
|
+
groups.push(current);
|
|
62
|
+
current = [];
|
|
63
|
+
used = 0;
|
|
64
|
+
budget = Math.max(maxBytes - envelopeBytes, 0);
|
|
65
|
+
}
|
|
66
|
+
if (size > budget)
|
|
67
|
+
oversized.push(session.name);
|
|
68
|
+
current.push(session);
|
|
69
|
+
used += size;
|
|
70
|
+
}
|
|
71
|
+
groups.push(current);
|
|
72
|
+
const parts = groups.length;
|
|
73
|
+
const messages = groups.map((group, index) => JSON.stringify({
|
|
74
|
+
type: 'listener.sessions',
|
|
75
|
+
data: { sessions: group, partial: true, reportId: id, part: index + 1, parts, ...(index === 0 ? { knownSessions } : {}) },
|
|
76
|
+
}));
|
|
77
|
+
const names = JSON.stringify({ type: 'listener.sessions.names', data: { names: sessions.map((s) => s.name), reportId: id } });
|
|
78
|
+
messages.push(names);
|
|
79
|
+
return { messages, sessionCount: sessions.length, wholeBytes, oversized, namesOversized: bytes(names) > maxBytes, reportId: id };
|
|
80
|
+
};
|
|
81
|
+
exports.planSessionsReport = planSessionsReport;
|
|
82
|
+
/** How long a part waits for its ack before the report is given up (and tried again on the next poll). */
|
|
83
|
+
exports.SESSIONS_ACK_TIMEOUT_MS = 10_000;
|
|
84
|
+
/**
|
|
85
|
+
* Send a plan's messages in order. With parts, every message but the last (`names`) waits for its
|
|
86
|
+
* ack before the next goes — `awaitAck(part)` is asked for before the send, so a quick answer is not
|
|
87
|
+
* missed, and `names` (which removes whatever it does not list) only goes once every part landed.
|
|
88
|
+
* False when an ack did not come: the rest is not sent, and the caller treats the report as unsent.
|
|
89
|
+
*/
|
|
90
|
+
const sendSessionsReport = async (messages, send, awaitAck) => {
|
|
91
|
+
for (let i = 0; i < messages.length; i++) {
|
|
92
|
+
const acked = messages.length > 1 && i < messages.length - 1 ? awaitAck(i + 1) : null;
|
|
93
|
+
send(messages[i]);
|
|
94
|
+
if (acked && !(await acked))
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
};
|
|
99
|
+
exports.sendSessionsReport = sendSessionsReport;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** One phone → shell-session injection, as the listener admitted it. */
|
|
2
|
+
export interface ShellAuditEntry {
|
|
3
|
+
at: Date;
|
|
4
|
+
session: string;
|
|
5
|
+
/** The pane the guard probed — the one the input was typed into. */
|
|
6
|
+
target: string;
|
|
7
|
+
kind: 'body' | 'insert' | 'keys';
|
|
8
|
+
/** `pane_current_command` when the input landed. */
|
|
9
|
+
foreground: string;
|
|
10
|
+
/** The foreground is the shell itself, so the text is a command line. */
|
|
11
|
+
atShell: boolean;
|
|
12
|
+
/** The text, or the key names joined by spaces. */
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const shellAuditPath: () => string;
|
|
16
|
+
/** Past this size the log moves to `shell-audit.log.1`, replacing the one before. */
|
|
17
|
+
export declare const SHELL_AUDIT_MAX_BYTES: number;
|
|
18
|
+
/**
|
|
19
|
+
* One tab-separated line. The text is kept only when it was typed at the shell
|
|
20
|
+
* prompt: anything else went to a program running in the pane — a `sudo`
|
|
21
|
+
* password prompt, an `ssh` login — and is recorded by length alone. Key names
|
|
22
|
+
* are never secret, so they are kept whatever the foreground is. The payload
|
|
23
|
+
* and every name are JSON-quoted, so a tab or newline in any of them cannot
|
|
24
|
+
* forge a field or a line.
|
|
25
|
+
*/
|
|
26
|
+
export declare const formatShellAuditLine: (e: ShellAuditEntry) => string;
|
|
27
|
+
/**
|
|
28
|
+
* Append one entry to the audit log, a 0600 file (the state dir it sits in is
|
|
29
|
+
* shared, and whichever writer creates it first sets its mode). Returns false
|
|
30
|
+
* instead of throwing: the log answers "what did the phone run", and a full
|
|
31
|
+
* disk must not also stop the phone from running it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const appendShellAudit: (entry: ShellAuditEntry, path?: string) => boolean;
|
|
34
|
+
//# sourceMappingURL=shell-audit.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.appendShellAudit = exports.formatShellAuditLine = exports.SHELL_AUDIT_MAX_BYTES = exports.shellAuditPath = void 0;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const gate_js_1 = require("./gate.js");
|
|
7
|
+
const private_files_js_1 = require("./private-files.js");
|
|
8
|
+
const shellAuditPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'shell-audit.log');
|
|
9
|
+
exports.shellAuditPath = shellAuditPath;
|
|
10
|
+
/** Past this size the log moves to `shell-audit.log.1`, replacing the one before. */
|
|
11
|
+
exports.SHELL_AUDIT_MAX_BYTES = 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* One tab-separated line. The text is kept only when it was typed at the shell
|
|
14
|
+
* prompt: anything else went to a program running in the pane — a `sudo`
|
|
15
|
+
* password prompt, an `ssh` login — and is recorded by length alone. Key names
|
|
16
|
+
* are never secret, so they are kept whatever the foreground is. The payload
|
|
17
|
+
* and every name are JSON-quoted, so a tab or newline in any of them cannot
|
|
18
|
+
* forge a field or a line.
|
|
19
|
+
*/
|
|
20
|
+
const formatShellAuditLine = (e) => {
|
|
21
|
+
const payload = e.atShell || e.kind === 'keys'
|
|
22
|
+
? JSON.stringify(e.text)
|
|
23
|
+
: `[${[...e.text].length} chars → ${JSON.stringify(e.foreground)}]`;
|
|
24
|
+
const q = JSON.stringify;
|
|
25
|
+
return [e.at.toISOString(), q(e.session), q(e.target), e.kind, q(e.foreground), payload].join('\t') + '\n';
|
|
26
|
+
};
|
|
27
|
+
exports.formatShellAuditLine = formatShellAuditLine;
|
|
28
|
+
/**
|
|
29
|
+
* Append one entry to the audit log, a 0600 file (the state dir it sits in is
|
|
30
|
+
* shared, and whichever writer creates it first sets its mode). Returns false
|
|
31
|
+
* instead of throwing: the log answers "what did the phone run", and a full
|
|
32
|
+
* disk must not also stop the phone from running it.
|
|
33
|
+
*/
|
|
34
|
+
const appendShellAudit = (entry, path = (0, exports.shellAuditPath)()) => {
|
|
35
|
+
try {
|
|
36
|
+
(0, private_files_js_1.mkdirHandedBack)((0, gate_js_1.stateDir)(), 0o700);
|
|
37
|
+
if (((0, fs_1.statSync)(path, { throwIfNoEntry: false })?.size ?? 0) >= exports.SHELL_AUDIT_MAX_BYTES)
|
|
38
|
+
(0, fs_1.renameSync)(path, `${path}.1`);
|
|
39
|
+
(0, fs_1.appendFileSync)(path, (0, exports.formatShellAuditLine)(entry), { mode: 0o600 });
|
|
40
|
+
(0, private_files_js_1.handBackToSudoUser)(path);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
exports.appendShellAudit = appendShellAudit;
|