workoffdesk 0.0.0-stage → 1.0.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +198 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-DinzDcF1.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-w7i8PvU7.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
@@ -0,0 +1,194 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync, renameSync } from 'node:fs';
3
+ import { DomainError } from '../system/errors.js';
4
+ // What is sent while Claude works goes to it at once, into its own queue, which it reads at its next step
5
+ // (journal/ingest.ts follows it there). What is sent while Claude starts or asks something waits here, to be
6
+ // looked over, changed or sent at once: a dialog would take what is typed. Claude takes nothing while it
7
+ // compacts, and an ended session is a shell that would run what it is sent.
8
+ const HOLDS = new Set(['starting', 'waiting', 'compacting', 'ended']);
9
+ // Apart, so Claude takes each message on its own rather than as one paste.
10
+ const GAP_MS = 400;
11
+ const queued = (m) => ({ id: m.id, text: m.text, at: m.sentAt });
12
+ // Where the messages were kept before the journal: read into it once, then set aside as <file>.migrated.
13
+ export function migrateQueueFile(file, store) {
14
+ if (!existsSync(file))
15
+ return;
16
+ const f = JSON.parse(readFileSync(file, 'utf8'));
17
+ const put = (id, m) => store.messages(id).some((r) => r.id === m.id) || store.putMessage(id, m);
18
+ const row = (q, state, ord) => ({ id: q.id, text: q.text, sentAt: q.at, state, ord, item: null });
19
+ for (const [id, list] of Object.entries(f.done ?? {}))
20
+ for (const qid of list)
21
+ put(id, { id: qid, text: '', sentAt: 0, state: 'landed', ord: null, item: null });
22
+ for (const [id, list] of Object.entries(f.sent ?? {}))
23
+ for (const q of list)
24
+ put(id, row(q, 'delivered', null));
25
+ for (const [id, list] of Object.entries(f.sessions ?? {}))
26
+ list.forEach((q, n) => put(id, row(q, 'queued', n + 1)));
27
+ renameSync(file, `${file}.migrated`);
28
+ }
29
+ // A delivery that was asked for but not answered (a tmux that timed out) may have gone in: typing it again
30
+ // could put it there twice.
31
+ const mayHaveRun = (e) => e?.mayHaveRun === true;
32
+ // What is sent to a Claude session reaches it in the order it was sent; what it cannot take yet waits, in
33
+ // the journal, through a restart, until it can. Each message is one row whose state says where it is:
34
+ // queued, delivering (being pasted), delivered, landed (the transcript has it), taken back, unconfirmed.
35
+ export class Outbox {
36
+ store;
37
+ status;
38
+ deliver;
39
+ changed;
40
+ failed;
41
+ gapMs;
42
+ running = new Map();
43
+ again = new Set();
44
+ // Messages being pasted from a send, by id, with what a send of the same id answers.
45
+ pastes = new Map();
46
+ constructor(deps) {
47
+ this.store = deps.store;
48
+ this.status = deps.status;
49
+ this.deliver = deps.deliver;
50
+ this.changed = deps.changed;
51
+ this.failed = deps.failed ?? (() => { });
52
+ this.gapMs = deps.gapMs ?? GAP_MS;
53
+ }
54
+ list(id) {
55
+ return this.waiting(id).map(queued);
56
+ }
57
+ // Null when the message went to Claude at once; otherwise it waits, behind any already waiting. One that
58
+ // failed to go waits for the next flush rather than being tried again at once. One sent again under the
59
+ // id it was given (`qid`) is taken once: what it got the first time comes back.
60
+ async send(id, text, qid = randomBytes(6).toString('hex')) {
61
+ const pasting = this.pastes.get(qid);
62
+ if (pasting)
63
+ return pasting;
64
+ const known = this.store.messages(id).find((m) => m.id === qid);
65
+ if (known?.state === 'queued')
66
+ return queued(known);
67
+ if (known && known.state !== 'taken')
68
+ return null;
69
+ const m = { id: qid, text, sentAt: Date.now(), state: 'queued', ord: null, item: null };
70
+ if (!this.waiting(id).length && !HOLDS.has(this.status(id))) {
71
+ return this.pasting(qid, async () => ((await this.paste(id, m)) ? null : (this.put(id, { ...m, ord: this.lastOrd(id) + 1 }), queued(m))));
72
+ }
73
+ this.put(id, { ...m, ord: this.lastOrd(id) + 1 });
74
+ void this.flush(id);
75
+ return queued(m);
76
+ }
77
+ // One run per session at a time; asked again while running, it looks once more before it ends.
78
+ flush(id) {
79
+ const run = this.running.get(id);
80
+ if (run) {
81
+ this.again.add(id);
82
+ return run;
83
+ }
84
+ // Started after it is registered, so a send made while delivering finds it running; it is unregistered
85
+ // in the same step that finds nothing more asked of it, so no flush falls between the two.
86
+ const next = Promise.resolve().then(async () => {
87
+ try {
88
+ do {
89
+ this.again.delete(id);
90
+ await this.deliverHeld(id);
91
+ } while (this.again.has(id));
92
+ }
93
+ finally {
94
+ this.running.delete(id);
95
+ }
96
+ });
97
+ this.running.set(id, next);
98
+ return next;
99
+ }
100
+ // The text of a message taken back before it went, or null when it is no longer waiting.
101
+ remove(id, qid) {
102
+ const m = this.waiting(id).find((w) => w.id === qid);
103
+ if (!m)
104
+ return null;
105
+ this.put(id, { ...m, state: 'taken', ord: null });
106
+ return m.text;
107
+ }
108
+ // Goes to Claude now, whatever it is doing, out of its turn; false when it is not waiting any more. One
109
+ // that could not go waits again, first, and the caller is told it did not go.
110
+ async sendNow(id, qid) {
111
+ const m = this.waiting(id).find((w) => w.id === qid);
112
+ if (!m)
113
+ return false;
114
+ const answer = await this.pasting(qid, async () => {
115
+ if (await this.paste(id, m))
116
+ return null;
117
+ this.put(id, { ...m, state: 'queued', ord: this.firstOrd(id) - 1 });
118
+ return queued(m);
119
+ });
120
+ if (answer)
121
+ throw new DomainError(503, 'not sent');
122
+ return true;
123
+ }
124
+ // One place earlier (-1) or later (1) among those waiting; false when it is not waiting any more.
125
+ move(id, qid, by) {
126
+ const list = this.waiting(id);
127
+ const i = list.findIndex((m) => m.id === qid);
128
+ if (i < 0)
129
+ return false;
130
+ const j = Math.max(0, Math.min(list.length - 1, i + by));
131
+ if (i === j)
132
+ return true;
133
+ this.put(id, { ...list[i], ord: list[j].ord });
134
+ this.put(id, { ...list[j], ord: list[i].ord });
135
+ return true;
136
+ }
137
+ async deliverHeld(id) {
138
+ let first = true;
139
+ for (let m = this.waiting(id)[0]; m && !HOLDS.has(this.status(id)); m = this.waiting(id)[0]) {
140
+ if (!first)
141
+ await new Promise((r) => setTimeout(r, this.gapMs));
142
+ first = false;
143
+ // Taken back or moved during the gap.
144
+ if (this.waiting(id)[0]?.id !== m.id)
145
+ continue;
146
+ if (!(await this.paste(id, m))) {
147
+ this.put(id, { ...m, state: 'queued' });
148
+ this.again.delete(id);
149
+ return;
150
+ }
151
+ }
152
+ }
153
+ // Pastes the message, recorded as delivering until it is in: true once it is (or may be, after a paste
154
+ // that was asked for and not answered), false when it did not go; then the caller says where it waits.
155
+ // Typed while Claude works, it goes into Claude's own queue, and is said to from the start (enqueued),
156
+ // so no device sees it anywhere else on the way.
157
+ async paste(id, m) {
158
+ this.put(id, { ...m, state: this.status(id) === 'working' ? 'enqueued' : 'delivering', ord: null });
159
+ let went = true;
160
+ try {
161
+ await this.deliver(id, m.text, m.id);
162
+ }
163
+ catch (e) {
164
+ this.failed(id, e);
165
+ went = mayHaveRun(e);
166
+ }
167
+ // The transcript may have shown it landed before the paste answered.
168
+ if (went && this.store.messages(id).find((r) => r.id === m.id)?.state === 'delivering')
169
+ this.put(id, { ...m, state: 'delivered', ord: null });
170
+ return went;
171
+ }
172
+ // Known as being delivered from before the paste starts until it is recorded as sent or held again.
173
+ pasting(qid, paste) {
174
+ const answer = paste().finally(() => this.pastes.delete(qid));
175
+ this.pastes.set(qid, answer);
176
+ return answer;
177
+ }
178
+ waiting(id) {
179
+ return this.store
180
+ .messages(id)
181
+ .filter((m) => m.state === 'queued')
182
+ .sort((a, b) => (a.ord ?? 0) - (b.ord ?? 0));
183
+ }
184
+ lastOrd(id) {
185
+ return this.waiting(id).at(-1)?.ord ?? 0;
186
+ }
187
+ firstOrd(id) {
188
+ return this.waiting(id)[0]?.ord ?? 0;
189
+ }
190
+ put(id, m) {
191
+ this.store.putMessage(id, m);
192
+ this.changed();
193
+ }
194
+ }
@@ -0,0 +1,60 @@
1
+ import { execFile } from 'node:child_process';
2
+ // An agent session's pane starts as `<shell> -lc '<agent> …; exec <shell> -l'`. That process waits for the
3
+ // agent and, however the agent ends, becomes the login shell: while its command line is still the wrapper's,
4
+ // the agent runs.
5
+ const WRAPPER = /^\S+ -lc /;
6
+ const LOOK_MS = 5_000;
7
+ const QUIT_LOOK_MS = 100;
8
+ const run = (bin, args) => new Promise((resolve) => execFile(bin, args, { timeout: LOOK_MS }, (err, stdout) => {
9
+ const code = err ? err.code : 0;
10
+ resolve(typeof code === 'number' ? { code, out: String(stdout) } : null);
11
+ }));
12
+ // Per pane process, whether the agent it was started with runs; one gone is not running. Null when it could not
13
+ // be looked at.
14
+ export async function agentsRunning(pids) {
15
+ if (!pids.length)
16
+ return new Map();
17
+ // ps leaves out the processes that are gone, and then exits 1.
18
+ const r = await run('/bin/ps', ['-ww', '-o', 'pid=,args=', '-p', pids.join(',')]);
19
+ if (!r || r.code > 1)
20
+ return null;
21
+ const args = new Map();
22
+ for (const line of r.out.split('\n')) {
23
+ const [, pid, command] = /^\s*(\d+)\s(.*)$/.exec(line) ?? [];
24
+ if (pid)
25
+ args.set(Number(pid), command);
26
+ }
27
+ return new Map(pids.map((pid) => [pid, WRAPPER.test(args.get(pid) ?? '')]));
28
+ }
29
+ // What runs under a pane process: its agent, while the agent runs; a program started from a terminal on the Mac,
30
+ // in the shell a pane was left with.
31
+ async function childrenOf(pid) {
32
+ const r = await run('/usr/bin/pgrep', ['-P', String(pid)]);
33
+ if (!r || r.code > 1)
34
+ throw new Error(`could not look at what runs under process ${pid}`);
35
+ return r.out.split('\n').filter(Boolean).map(Number);
36
+ }
37
+ export async function shellBusy(pid) {
38
+ return (await childrenOf(pid)).length > 0;
39
+ }
40
+ // Asks the agent a pane runs to quit, as a terminal's is when it is taken over, and waits for it at most waitMs:
41
+ // true once it no longer runs.
42
+ export async function quitAgent(pid, waitMs) {
43
+ for (const child of await childrenOf(pid)) {
44
+ try {
45
+ process.kill(child, 'SIGTERM');
46
+ }
47
+ catch (e) {
48
+ if (e.code !== 'ESRCH')
49
+ throw e;
50
+ }
51
+ }
52
+ const until = Date.now() + waitMs;
53
+ for (;;) {
54
+ if ((await agentsRunning([pid]))?.get(pid) === false)
55
+ return true;
56
+ if (Date.now() > until)
57
+ return false;
58
+ await new Promise((r) => setTimeout(r, QUIT_LOOK_MS));
59
+ }
60
+ }
@@ -0,0 +1,7 @@
1
+ // A session's tmux session is named for it, so the sessions this server runs are told apart from any other
2
+ // on the same tmux server.
3
+ const PREFIX = 'wod-';
4
+ export const tmuxName = (id) => PREFIX + id;
5
+ export function idOfTmuxName(name) {
6
+ return name.startsWith(PREFIX) ? name.slice(PREFIX.length) : undefined;
7
+ }
@@ -0,0 +1,40 @@
1
+ import { execFile } from 'node:child_process';
2
+ // What a Claude started here in a terminal's place keeps of that terminal's environment: all of it, so what
3
+ // was set up there goes on working (a virtualenv, a cloud profile, the hooks of the app the terminal is in),
4
+ // but for what describes the terminal itself, which tmux sets anew, and what marks a program run inside another.
5
+ const OF_THE_TERMINAL = new Set([
6
+ 'TERM',
7
+ 'TERM_PROGRAM',
8
+ 'TERM_PROGRAM_VERSION',
9
+ 'COLORTERM',
10
+ 'TMUX',
11
+ 'TMUX_PANE',
12
+ 'SHLVL',
13
+ 'PWD',
14
+ 'OLDPWD',
15
+ '_',
16
+ 'CLAUDECODE',
17
+ 'CLAUDE_CODE_ENTRYPOINT',
18
+ 'WORKOFFDESK_SESSION',
19
+ ]);
20
+ // A shell's exported functions (BASH_FUNC_name%%) are not variables tmux can set; the login shell defines its own.
21
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
22
+ export function fromTerminal(env) {
23
+ return Object.fromEntries(Object.entries(env).filter((e) => typeof e[1] === 'string' && NAME.test(e[0]) && !OF_THE_TERMINAL.has(e[0])));
24
+ }
25
+ const PS_MS = 5_000;
26
+ const ps = (args) => new Promise((resolve, reject) => execFile('/bin/ps', ['-ww', ...args], { timeout: PS_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => err ? reject(err) : resolve(String(stdout).replace(/\n$/, ''))));
27
+ // The environment a process of this user started with, as ps shows it after its arguments: NAME=value, one
28
+ // after another with a space between, so a value holding " NAME=" is cut there. Empty when it is gone.
29
+ export async function environmentOf(pid) {
30
+ const [args, withEnv] = await Promise.all([ps(['-o', 'args=', '-p', String(pid)]), ps(['-E', '-o', 'args=', '-p', String(pid)])]).catch(() => ['', '']);
31
+ if (!args || !withEnv.startsWith(`${args} `))
32
+ return {};
33
+ return Object.fromEntries(withEnv
34
+ .slice(args.length + 1)
35
+ .split(/ (?=[A-Za-z_][A-Za-z0-9_]*=)/)
36
+ .flatMap((pair) => {
37
+ const at = pair.indexOf('=');
38
+ return at > 0 ? [[pair.slice(0, at), pair.slice(at + 1)]] : [];
39
+ }));
40
+ }
@@ -0,0 +1,71 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { m } from './messages.js';
4
+ import { writeJsonAtomic } from './paths.js';
5
+ export const DEFAULT_PORT = 7681;
6
+ export const DEFAULT_SERVICE_LABEL = 'local.workoffdesk';
7
+ export class ConfigError extends Error {
8
+ field;
9
+ expected;
10
+ constructor(field, expected) {
11
+ super(m.config.wrong(field, expected));
12
+ this.field = field;
13
+ this.expected = expected;
14
+ }
15
+ }
16
+ const LOCAL = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
17
+ const isStrings = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
18
+ function originUrl(origin) {
19
+ if (typeof origin !== 'string')
20
+ return null;
21
+ try {
22
+ const url = new URL(origin);
23
+ return url.origin === origin && (url.protocol === 'https:' || LOCAL.test(origin)) ? url : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function parseConfig(raw) {
30
+ const r = (raw && typeof raw === 'object' ? raw : {});
31
+ const url = originUrl(r.origin);
32
+ if (!url)
33
+ throw new ConfigError('origin', m.config.origin);
34
+ const port = r.port ?? DEFAULT_PORT;
35
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
36
+ throw new ConfigError('port', m.config.port);
37
+ const rpId = r.rpId ?? url.hostname;
38
+ if (typeof rpId !== 'string' || !(url.hostname === rpId || url.hostname.endsWith(`.${rpId}`))) {
39
+ throw new ConfigError('rpId', m.config.rpId(url.hostname));
40
+ }
41
+ const pinnedDirs = r.pinnedDirs ?? [];
42
+ if (!isStrings(pinnedDirs))
43
+ throw new ConfigError('pinnedDirs', m.config.pinnedDirs);
44
+ const serviceLabel = r.serviceLabel ?? DEFAULT_SERVICE_LABEL;
45
+ if (typeof serviceLabel !== 'string' || !/^[A-Za-z0-9.-]+$/.test(serviceLabel)) {
46
+ throw new ConfigError('serviceLabel', m.config.serviceLabel);
47
+ }
48
+ const claudeArgs = r.claudeArgs ?? [];
49
+ if (!isStrings(claudeArgs))
50
+ throw new ConfigError('claudeArgs', m.config.claudeArgs);
51
+ const preventSleep = r.preventSleep ?? true;
52
+ if (typeof preventSleep !== 'boolean')
53
+ throw new ConfigError('preventSleep', m.config.preventSleep);
54
+ return { port, origin: url.origin, rpId, pinnedDirs, serviceLabel, claudeArgs, preventSleep };
55
+ }
56
+ export function loadConfig(home) {
57
+ const file = join(home, 'config.json');
58
+ if (!existsSync(file))
59
+ throw new ConfigError('origin', m.config.missing(file));
60
+ let raw;
61
+ try {
62
+ raw = JSON.parse(readFileSync(file, 'utf8'));
63
+ }
64
+ catch (e) {
65
+ throw new ConfigError(m.config.format, m.config.notJson(e.message));
66
+ }
67
+ return parseConfig(raw);
68
+ }
69
+ export function writeConfig(home, c) {
70
+ writeJsonAtomic(join(home, 'config.json'), c);
71
+ }
@@ -0,0 +1,54 @@
1
+ import { statSync } from 'node:fs';
2
+ import { readdir, realpath, stat } from 'node:fs/promises';
3
+ import { join, resolve, sep } from 'node:path';
4
+ // How many folders one listing gives at most: the picker shows a screenful, and typing narrows it.
5
+ export const DIRS_MAX = 50;
6
+ export function isDir(path) {
7
+ try {
8
+ return statSync(path).isDirectory();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ // A path as typed, with a leading ~ standing for home; anything else as it is.
15
+ export function expandHome(path, home) {
16
+ if (path === '~')
17
+ return home;
18
+ if (path.startsWith('~/'))
19
+ return join(home, path.slice(2));
20
+ return path;
21
+ }
22
+ const within = (path, root) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
23
+ const realOrNull = (path) => realpath(path).catch(() => null);
24
+ const byName = (a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' });
25
+ // The folders a path typed in the new-session picker points into: those in the folder before its last '/'
26
+ // whose names start with what comes after it, ignoring case, and those named with a leading '.' only when
27
+ // that part has one too. Nothing outside home is listed, however it is reached (.., a link).
28
+ export async function listDirs(typed, home) {
29
+ const path = typed === '~' ? '~/' : typed;
30
+ const cut = path.lastIndexOf('/');
31
+ const parent = expandHome(path.slice(0, cut + 1), home);
32
+ if (!parent.startsWith('/'))
33
+ return [];
34
+ const dir = resolve(parent);
35
+ const prefix = path.slice(cut + 1).toLowerCase();
36
+ const [realHome, realDir] = await Promise.all([realOrNull(home), realOrNull(dir)]);
37
+ if (!realHome || !realDir || !within(realDir, realHome))
38
+ return [];
39
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
40
+ const names = entries
41
+ .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && e.name.toLowerCase().startsWith(prefix))
42
+ .filter((e) => prefix.startsWith('.') || !e.name.startsWith('.'))
43
+ .map((e) => e.name)
44
+ .sort(byName);
45
+ const out = [];
46
+ for (const name of names) {
47
+ if (out.length === DIRS_MAX)
48
+ break;
49
+ const real = await realOrNull(join(dir, name));
50
+ if (real && within(real, realHome) && (await stat(real).then((s) => s.isDirectory(), () => false)))
51
+ out.push(join(dir, name));
52
+ }
53
+ return out;
54
+ }
@@ -0,0 +1,37 @@
1
+ // What can go wrong in a way the caller should hear about, with the HTTP status it is answered with.
2
+ // Anything else is the server's own fault: answered 500 with no detail, and logged in full.
3
+ // code: for the ones the phone shows, which it says in the phone's own language.
4
+ export class DomainError extends Error {
5
+ status;
6
+ code;
7
+ constructor(status, message, code) {
8
+ super(message);
9
+ this.status = status;
10
+ this.code = code;
11
+ }
12
+ }
13
+ export class InvalidRequest extends DomainError {
14
+ constructor(message, code) {
15
+ super(400, message, code);
16
+ }
17
+ }
18
+ export class NotFound extends DomainError {
19
+ constructor(message) {
20
+ super(404, message);
21
+ }
22
+ }
23
+ export class Conflict extends DomainError {
24
+ constructor(message, code) {
25
+ super(409, message, code);
26
+ }
27
+ }
28
+ export class UnsupportedMedia extends DomainError {
29
+ constructor(message, code) {
30
+ super(415, message, code);
31
+ }
32
+ }
33
+ export function errorReply(e) {
34
+ if (!(e instanceof DomainError))
35
+ return { status: 500, body: { error: 'server error' } };
36
+ return { status: e.status, body: e.code ? { error: e.message, code: e.code } : { error: e.message } };
37
+ }
@@ -0,0 +1,40 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import pino from 'pino';
4
+ export const REDACT = ['req.headers.cookie', 'req.headers.authorization', 'req.body.password', 'res.headers["set-cookie"]'];
5
+ // A log file that is moved aside to .1 when it is full, the older ones moving up to .keep and then gone.
6
+ // Writes are synchronous: the lines are few and short, and none is lost when the process exits.
7
+ export class RotatingFile {
8
+ path;
9
+ size;
10
+ maxBytes;
11
+ keep;
12
+ constructor(path, o = {}) {
13
+ this.path = path;
14
+ this.maxBytes = o.maxBytes ?? 10 * 1024 * 1024;
15
+ this.keep = o.keep ?? 3;
16
+ mkdirSync(dirname(path), { recursive: true });
17
+ this.size = existsSync(path) ? statSync(path).size : 0;
18
+ }
19
+ write(chunk) {
20
+ const bytes = Buffer.byteLength(chunk);
21
+ if (this.size > 0 && this.size + bytes > this.maxBytes)
22
+ this.rotate();
23
+ appendFileSync(this.path, chunk, { mode: 0o600 });
24
+ this.size += bytes;
25
+ return true;
26
+ }
27
+ rotate() {
28
+ rmSync(`${this.path}.${this.keep}`, { force: true });
29
+ for (let n = this.keep - 1; n >= 1; n--) {
30
+ if (existsSync(`${this.path}.${n}`))
31
+ renameSync(`${this.path}.${n}`, `${this.path}.${n + 1}`);
32
+ }
33
+ renameSync(this.path, `${this.path}.1`);
34
+ this.size = 0;
35
+ }
36
+ }
37
+ export function createLogger(o = {}) {
38
+ const destination = o.file ? new RotatingFile(o.file) : process.stdout;
39
+ return pino({ level: o.level ?? 'info', redact: { paths: REDACT, censor: '[redacted]' } }, destination);
40
+ }