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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
3
|
+
import { readBuildStamp, staleBuild } from './build.js';
|
|
4
|
+
import { m } from '../system/messages.js';
|
|
5
|
+
import { restartService } from './service.js';
|
|
6
|
+
const KEEP = 3;
|
|
7
|
+
const MARKER = '.complete';
|
|
8
|
+
const PARTIAL = '.partial';
|
|
9
|
+
export const ACTIVATED = '.activated';
|
|
10
|
+
export const releasesDir = (home) => join(home, 'releases');
|
|
11
|
+
const appLink = (home) => join(home, 'app');
|
|
12
|
+
// The marker is written last, so a release without one is a copy that never finished.
|
|
13
|
+
// It carries the order releases were made in, which does not depend on the clock.
|
|
14
|
+
function seqOf(home, name) {
|
|
15
|
+
try {
|
|
16
|
+
const { seq } = JSON.parse(readFileSync(join(releasesDir(home), name, MARKER), 'utf8'));
|
|
17
|
+
return typeof seq === 'number' ? seq : null;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function markComplete(dir, seq, at) {
|
|
24
|
+
writeFileSync(join(dir, MARKER), JSON.stringify({ seq, at: at.toISOString() }));
|
|
25
|
+
}
|
|
26
|
+
export function listReleases(home) {
|
|
27
|
+
if (!existsSync(releasesDir(home)))
|
|
28
|
+
return [];
|
|
29
|
+
return readdirSync(releasesDir(home))
|
|
30
|
+
.filter((n) => !n.endsWith(PARTIAL))
|
|
31
|
+
.map((name) => ({ name, seq: seqOf(home, name) }))
|
|
32
|
+
.filter((r) => r.seq !== null)
|
|
33
|
+
.sort((a, b) => b.seq - a.seq)
|
|
34
|
+
.map((r) => r.name);
|
|
35
|
+
}
|
|
36
|
+
const nextSeq = (home) => Math.max(0, ...listReleases(home).map((n) => seqOf(home, n))) + 1;
|
|
37
|
+
export function currentRelease(home) {
|
|
38
|
+
try {
|
|
39
|
+
return basename(readlinkSync(appLink(home)));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function pointAppAt(home, name) {
|
|
46
|
+
const tmp = `${appLink(home)}.tmp`;
|
|
47
|
+
rmSync(tmp, { force: true });
|
|
48
|
+
symlinkSync(join(releasesDir(home), name), tmp);
|
|
49
|
+
renameSync(tmp, appLink(home));
|
|
50
|
+
}
|
|
51
|
+
// The path a service should start `cli` by. Node hands a script its real path, so a cli run through
|
|
52
|
+
// the app link names one release; started by that path, the service would outlive every later deploy.
|
|
53
|
+
export function launchEntry(cli, home) {
|
|
54
|
+
let releases;
|
|
55
|
+
try {
|
|
56
|
+
releases = realpathSync(releasesDir(home));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return cli;
|
|
60
|
+
}
|
|
61
|
+
const [release, ...rest] = relative(releases, cli).split(sep);
|
|
62
|
+
if (release === '..')
|
|
63
|
+
return cli;
|
|
64
|
+
return join(appLink(home), ...rest);
|
|
65
|
+
}
|
|
66
|
+
const removeRelease = (home, name) => rmSync(join(releasesDir(home), name), { recursive: true, force: true });
|
|
67
|
+
const isDir = (p) => existsSync(p) && lstatSync(p).isDirectory();
|
|
68
|
+
const isBuild = (dir) => existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'dist/server/cli.js'));
|
|
69
|
+
// Builds from before 2.0.0 have no /healthz. A build has it under http/ when its server is laid out by layer.
|
|
70
|
+
const HEALTH_ROUTE = ['dist/server/http/routes/health.js', 'dist/server/routes/health.js'];
|
|
71
|
+
const reportsVersion = (dir) => HEALTH_ROUTE.some((f) => existsSync(join(dir, f)));
|
|
72
|
+
export const versionOf = (dir) => String(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version);
|
|
73
|
+
// v1 ran from a plain app folder, with the build before it in app.old.
|
|
74
|
+
export function migrateLayout(home) {
|
|
75
|
+
const app = appLink(home);
|
|
76
|
+
const old = join(home, 'app.old');
|
|
77
|
+
if (!isDir(app) && !isDir(old))
|
|
78
|
+
return;
|
|
79
|
+
mkdirSync(releasesDir(home), { recursive: true });
|
|
80
|
+
const at = new Date(0);
|
|
81
|
+
if (isDir(old)) {
|
|
82
|
+
const dir = join(releasesDir(home), 'v1-previous');
|
|
83
|
+
renameSync(old, dir);
|
|
84
|
+
markComplete(dir, nextSeq(home), at);
|
|
85
|
+
}
|
|
86
|
+
if (isDir(app)) {
|
|
87
|
+
const dir = join(releasesDir(home), 'v1-current');
|
|
88
|
+
renameSync(app, dir);
|
|
89
|
+
markComplete(dir, nextSeq(home), at);
|
|
90
|
+
pointAppAt(home, 'v1-current');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
94
|
+
const stamp = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
95
|
+
function freshName(home, at) {
|
|
96
|
+
const base = stamp(at);
|
|
97
|
+
let name = base;
|
|
98
|
+
for (let n = 2; existsSync(join(releasesDir(home), name)); n++)
|
|
99
|
+
name = `${base}-${n}`;
|
|
100
|
+
return name;
|
|
101
|
+
}
|
|
102
|
+
// Points app at a release and restarts; when it does not come up, points app back where it was. The
|
|
103
|
+
// release learns when it began to serve before it starts; the one gone back to keeps its time, as it
|
|
104
|
+
// never stopped serving.
|
|
105
|
+
async function switchTo(d, name, from) {
|
|
106
|
+
const dir = join(releasesDir(d.home), name);
|
|
107
|
+
writeFileSync(join(dir, ACTIVATED), (d.now ?? (() => new Date()))().toISOString());
|
|
108
|
+
pointAppAt(d.home, name);
|
|
109
|
+
await restartService(d);
|
|
110
|
+
if (await d.health(reportsVersion(dir) ? versionOf(dir) : null))
|
|
111
|
+
return true;
|
|
112
|
+
if (from) {
|
|
113
|
+
pointAppAt(d.home, from);
|
|
114
|
+
await restartService(d);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
unlinkSync(appLink(d.home));
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
// The native runtime of the iPhone app the build's JS update is for; the server serves it to no other.
|
|
122
|
+
function appRuntimeOf(dir) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(join(dir, 'dist/app-update/ios/update.json'), 'utf8')).runtimeVersion ?? null;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function prune(home, keep) {
|
|
131
|
+
for (const name of readdirSync(releasesDir(home))) {
|
|
132
|
+
if (name.endsWith(PARTIAL))
|
|
133
|
+
removeRelease(home, name);
|
|
134
|
+
}
|
|
135
|
+
for (const name of listReleases(home).slice(KEEP)) {
|
|
136
|
+
if (!keep.includes(name))
|
|
137
|
+
removeRelease(home, name);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// newAppRuntime: the iPhone update may be for another runtime than the live one's, or go, as when a new build of
|
|
141
|
+
// the app is out. Without it, phones on the live runtime would be left with no updates and nothing would say so.
|
|
142
|
+
export async function deploy(src, d, { newAppRuntime = false } = {}) {
|
|
143
|
+
if (!isBuild(src))
|
|
144
|
+
return { ok: false, release: '', message: m.release.notABuild(src) };
|
|
145
|
+
const stale = staleBuild(src);
|
|
146
|
+
if (stale)
|
|
147
|
+
return { ok: false, release: '', message: m.release.staleBuild(stale.version, stale.built) };
|
|
148
|
+
migrateLayout(d.home);
|
|
149
|
+
const live = currentRelease(d.home);
|
|
150
|
+
const liveRuntime = live ? appRuntimeOf(join(releasesDir(d.home), live)) : null;
|
|
151
|
+
if (liveRuntime && appRuntimeOf(src) !== liveRuntime && !newAppRuntime)
|
|
152
|
+
return { ok: false, release: '', message: m.release.appRuntimeChanged(liveRuntime, appRuntimeOf(src)) };
|
|
153
|
+
mkdirSync(releasesDir(d.home), { recursive: true });
|
|
154
|
+
const at = (d.now ?? (() => new Date()))();
|
|
155
|
+
const name = freshName(d.home, at);
|
|
156
|
+
const partial = join(releasesDir(d.home), `${name}${PARTIAL}`);
|
|
157
|
+
cpSync(src, partial, { recursive: true, verbatimSymlinks: true });
|
|
158
|
+
markComplete(partial, nextSeq(d.home), at);
|
|
159
|
+
renameSync(partial, join(releasesDir(d.home), name));
|
|
160
|
+
const dir = join(releasesDir(d.home), name);
|
|
161
|
+
if (!(await d.smoke(dir))) {
|
|
162
|
+
removeRelease(d.home, name);
|
|
163
|
+
return { ok: false, release: name, message: m.release.smokeFailed(versionOf(src)) };
|
|
164
|
+
}
|
|
165
|
+
const prev = currentRelease(d.home);
|
|
166
|
+
if (!(await switchTo(d, name, prev))) {
|
|
167
|
+
removeRelease(d.home, name);
|
|
168
|
+
return { ok: false, release: name, message: m.release.didNotComeUp(versionOf(src), prev) };
|
|
169
|
+
}
|
|
170
|
+
prune(d.home, [name, prev]);
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
release: name,
|
|
174
|
+
message: [m.release.deployed(versionOf(dir), name), m.release.builtFrom(readBuildStamp(dir)), m.release.appUpdate(appRuntimeOf(dir))].join('\n'),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// The release rollback goes to: the newest one that is not running.
|
|
178
|
+
export const rollbackTarget = (home) => listReleases(home).find((n) => n !== currentRelease(home));
|
|
179
|
+
export async function rollback(d) {
|
|
180
|
+
migrateLayout(d.home);
|
|
181
|
+
const current = currentRelease(d.home);
|
|
182
|
+
const target = rollbackTarget(d.home);
|
|
183
|
+
if (!target)
|
|
184
|
+
return { ok: false, release: '', message: m.release.nothingToRollBackTo };
|
|
185
|
+
if (!(await switchTo(d, target, current))) {
|
|
186
|
+
return { ok: false, release: target, message: m.release.rollbackFailed(target, current) };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, release: target, message: m.release.rolledBack(versionOf(join(releasesDir(d.home), target)), target) };
|
|
189
|
+
}
|
|
190
|
+
// Polls the service on its loopback port until it reports `version`, or for a build with no /healthz
|
|
191
|
+
// until anything answers there.
|
|
192
|
+
export function healthOn(port, waitMs) {
|
|
193
|
+
return async (version) => {
|
|
194
|
+
const until = Date.now() + waitMs;
|
|
195
|
+
while (Date.now() < until) {
|
|
196
|
+
const res = await fetch(`http://127.0.0.1:${port}/healthz`).catch(() => null);
|
|
197
|
+
if (res && version === null)
|
|
198
|
+
return true;
|
|
199
|
+
const health = res?.ok ? (await res.json().catch(() => null)) : null;
|
|
200
|
+
if (health?.version === version)
|
|
201
|
+
return true;
|
|
202
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { readBuildStamp } from './build.js';
|
|
4
|
+
import { ACTIVATED, currentRelease, listReleases, releasesDir, rollbackTarget, versionOf } from './release.js';
|
|
5
|
+
import { VERSION } from '../system/version.js';
|
|
6
|
+
import { readLastOp } from './lastOp.js';
|
|
7
|
+
const activatedAt = (dir) => {
|
|
8
|
+
try {
|
|
9
|
+
return readFileSync(join(dir, ACTIVATED), 'utf8').trim() || null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export function releasesOf(home) {
|
|
16
|
+
return listReleases(home).map((name) => {
|
|
17
|
+
const dir = join(releasesDir(home), name);
|
|
18
|
+
let version = '';
|
|
19
|
+
try {
|
|
20
|
+
version = versionOf(dir);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// a release whose package.json is unreadable still shows by name
|
|
24
|
+
}
|
|
25
|
+
return { name, version, activatedAt: activatedAt(dir) };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export async function macReport(o, messages) {
|
|
29
|
+
return {
|
|
30
|
+
checks: await o.doctor(messages),
|
|
31
|
+
version: VERSION,
|
|
32
|
+
build: readBuildStamp(o.buildRoot),
|
|
33
|
+
release: currentRelease(o.home) ?? null,
|
|
34
|
+
releases: releasesOf(o.home),
|
|
35
|
+
rollbackTo: rollbackTarget(o.home) ?? null,
|
|
36
|
+
managed: o.managed,
|
|
37
|
+
startedAt: o.startedAt,
|
|
38
|
+
lastOp: readLastOp(o.home),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The service as launchd has it: started, stopped, restarted, and whether a process is it.
|
|
2
|
+
const target = (d) => `gui/${d.uid}/${d.label}`;
|
|
3
|
+
async function launchctl(d, args) {
|
|
4
|
+
const r = await d.exec('launchctl', args);
|
|
5
|
+
if (r.code !== 0)
|
|
6
|
+
throw new Error(r.out.trim() || `launchctl ${args[0]} failed (${r.code})`);
|
|
7
|
+
}
|
|
8
|
+
const loaded = async (d) => (await d.exec('launchctl', ['print', target(d)])).code === 0;
|
|
9
|
+
const pidOf = async (d) => /\bpid = (\d+)/.exec((await d.exec('launchctl', ['print', target(d)])).out)?.[1];
|
|
10
|
+
// Loads the agent install wrote, or starts the one launchd has.
|
|
11
|
+
export async function startService(d, plist) {
|
|
12
|
+
if (await loaded(d))
|
|
13
|
+
await launchctl(d, ['kickstart', target(d)]);
|
|
14
|
+
else
|
|
15
|
+
await launchctl(d, ['bootstrap', `gui/${d.uid}`, plist]);
|
|
16
|
+
}
|
|
17
|
+
// Unloaded rather than killed: KeepAlive would start a killed one again.
|
|
18
|
+
export async function stopService(d) {
|
|
19
|
+
if (await loaded(d))
|
|
20
|
+
await launchctl(d, ['bootout', target(d)]);
|
|
21
|
+
}
|
|
22
|
+
// How long the running service is given to close on its own (it allows itself 10 s), and how often it is looked at.
|
|
23
|
+
const STOP_WAIT_MS = 12_000;
|
|
24
|
+
const STOP_POLL_MS = 250;
|
|
25
|
+
// The service is asked to stop with SIGTERM, so it tells the phones it is restarting and closes cleanly
|
|
26
|
+
// (kickstart -k kills it outright), then started at once rather than after launchd's throttle. Only one
|
|
27
|
+
// that will not stop is killed.
|
|
28
|
+
export async function restartService(d) {
|
|
29
|
+
const was = await pidOf(d);
|
|
30
|
+
if (was) {
|
|
31
|
+
await d.exec('launchctl', ['kill', 'SIGTERM', target(d)]);
|
|
32
|
+
for (let t = 0; t < STOP_WAIT_MS && (await pidOf(d)) === was; t += STOP_POLL_MS)
|
|
33
|
+
await new Promise((r) => setTimeout(r, STOP_POLL_MS));
|
|
34
|
+
}
|
|
35
|
+
const stuck = was !== undefined && (await pidOf(d)) === was;
|
|
36
|
+
await d.exec('launchctl', ['kickstart', ...(stuck ? ['-k'] : []), target(d)]);
|
|
37
|
+
}
|
|
38
|
+
// launchd tells a job its own label in XPC_SERVICE_NAME; a server started by hand has another or none.
|
|
39
|
+
export const isManaged = (env, label) => env.XPC_SERVICE_NAME === label;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What a signal handler runs: close everything once, then exit 0; exit 1 if closing fails or takes
|
|
2
|
+
// longer than the limit, so a supervisor is never left waiting on a server that will not stop.
|
|
3
|
+
export function shutdownOnce(close, o) {
|
|
4
|
+
let started = false;
|
|
5
|
+
return () => {
|
|
6
|
+
if (started)
|
|
7
|
+
return;
|
|
8
|
+
started = true;
|
|
9
|
+
setTimeout(() => o.exit(1), o.timeoutMs).unref();
|
|
10
|
+
close().then(() => o.exit(0), () => o.exit(1));
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { request, createServer } from 'node:http';
|
|
2
|
+
import { listenOwnerOnly } from '../agents/claude/hooks/hooks.js';
|
|
3
|
+
import { errorReply } from '../system/errors.js';
|
|
4
|
+
// An environment's whole size is bounded by what exec takes on macOS, 1 MiB.
|
|
5
|
+
const MAX_BODY = 1024 * 1024;
|
|
6
|
+
const isEnv = (x) => typeof x === 'object' && x !== null && !Array.isArray(x) && Object.values(x).every((v) => typeof v === 'string');
|
|
7
|
+
const bad = (error) => ({ status: 400, body: { error } });
|
|
8
|
+
function openSession(open, body) {
|
|
9
|
+
const r = body;
|
|
10
|
+
if (typeof r?.cwd !== 'string' || (r.resumeId !== undefined && typeof r.resumeId !== 'string'))
|
|
11
|
+
return bad('cwd required');
|
|
12
|
+
if (r.env !== undefined && !isEnv(r.env))
|
|
13
|
+
return bad('env must be names to strings');
|
|
14
|
+
if (r.kind !== undefined && r.kind !== 'claude' && r.kind !== 'codex')
|
|
15
|
+
return bad('kind must be claude or codex');
|
|
16
|
+
return open({ kind: r.kind, cwd: r.cwd, resumeId: r.resumeId, env: r.env }).then((id) => ({ status: 201, body: { id } }), (e) => ({ status: 400, body: { error: e instanceof Error ? e.message : String(e) } }));
|
|
17
|
+
}
|
|
18
|
+
export function startControlServer(sockPath, o) {
|
|
19
|
+
const answered = (view) => view.then((body) => ({ status: 200, body }), (e) => {
|
|
20
|
+
const r = errorReply(e);
|
|
21
|
+
if (r.status === 500)
|
|
22
|
+
console.error(`workoffdesk: the control socket could not answer: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
|
|
23
|
+
return r;
|
|
24
|
+
});
|
|
25
|
+
const routes = {
|
|
26
|
+
'POST /sessions': (r) => openSession(o.open, r),
|
|
27
|
+
'GET /accounts': () => answered(o.accounts.view()),
|
|
28
|
+
'PUT /account': (r) => {
|
|
29
|
+
const id = r?.id;
|
|
30
|
+
return typeof id === 'string' ? answered(o.accounts.pick(id)) : bad('bad id');
|
|
31
|
+
},
|
|
32
|
+
'POST /account/move': () => answered(o.accounts.move()),
|
|
33
|
+
};
|
|
34
|
+
const server = createServer((req, res) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
req.on('data', (c) => {
|
|
38
|
+
size += c.length;
|
|
39
|
+
if (size > MAX_BODY)
|
|
40
|
+
req.destroy();
|
|
41
|
+
else
|
|
42
|
+
chunks.push(c);
|
|
43
|
+
});
|
|
44
|
+
req.on('end', () => {
|
|
45
|
+
const reply = ({ status, body }) => {
|
|
46
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
47
|
+
res.end(JSON.stringify(body));
|
|
48
|
+
};
|
|
49
|
+
const route = routes[`${req.method} ${req.url}`];
|
|
50
|
+
if (!route)
|
|
51
|
+
return reply({ status: 404, body: { error: 'not found' } });
|
|
52
|
+
let body;
|
|
53
|
+
try {
|
|
54
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
55
|
+
body = text ? JSON.parse(text) : undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return reply(bad('bad json'));
|
|
59
|
+
}
|
|
60
|
+
void Promise.resolve(route(body)).then(reply);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
return listenOwnerOnly(server, sockPath);
|
|
64
|
+
}
|
|
65
|
+
// What the server answered, or why it did not: its message, with its code when it gave one.
|
|
66
|
+
function ask(sockPath, method, path, body) {
|
|
67
|
+
return new Promise((done, fail) => {
|
|
68
|
+
const req = request({ socketPath: sockPath, method, path, headers: { 'content-type': 'application/json' } }, (res) => {
|
|
69
|
+
const chunks = [];
|
|
70
|
+
res.on('data', (c) => chunks.push(c));
|
|
71
|
+
res.on('end', () => {
|
|
72
|
+
let answer = {};
|
|
73
|
+
try {
|
|
74
|
+
answer = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// reported below
|
|
78
|
+
}
|
|
79
|
+
if (res.statusCode >= 200 && res.statusCode < 300)
|
|
80
|
+
return done(answer);
|
|
81
|
+
fail(Object.assign(new Error(answer.error ?? `server answered ${res.statusCode}`), answer.code ? { code: answer.code } : {}));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
req.on('error', fail);
|
|
85
|
+
req.end(body === undefined ? undefined : JSON.stringify(body));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// The id of the session the server opened, or the reason it did not.
|
|
89
|
+
export async function askToOpen(sockPath, r) {
|
|
90
|
+
const { id } = await ask(sockPath, 'POST', '/sessions', r);
|
|
91
|
+
if (!id)
|
|
92
|
+
throw new Error('server answered no session');
|
|
93
|
+
return id;
|
|
94
|
+
}
|
|
95
|
+
export const askAccounts = (sockPath) => ask(sockPath, 'GET', '/accounts');
|
|
96
|
+
export const askToPick = (sockPath, id) => ask(sockPath, 'PUT', '/account', { id });
|
|
97
|
+
export const askToMove = (sockPath) => ask(sockPath, 'POST', '/account/move');
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export class EventHub {
|
|
2
|
+
machine;
|
|
3
|
+
lastReply;
|
|
4
|
+
clients = new Set();
|
|
5
|
+
pending = null;
|
|
6
|
+
pendingHeads = null;
|
|
7
|
+
constructor(machine, lastReply = async () => null) {
|
|
8
|
+
this.machine = machine;
|
|
9
|
+
this.lastReply = lastReply;
|
|
10
|
+
machine.onChange(() => this.schedule());
|
|
11
|
+
machine.journal.onHead(() => this.scheduleHeads());
|
|
12
|
+
}
|
|
13
|
+
async add(socket) {
|
|
14
|
+
this.clients.add(socket);
|
|
15
|
+
const ping = setInterval(() => socket.ping(), 25_000);
|
|
16
|
+
socket.on('close', () => {
|
|
17
|
+
clearInterval(ping);
|
|
18
|
+
this.clients.delete(socket);
|
|
19
|
+
});
|
|
20
|
+
const snapshot = await this.snapshot();
|
|
21
|
+
if (snapshot)
|
|
22
|
+
socket.send(JSON.stringify(snapshot));
|
|
23
|
+
socket.send(JSON.stringify(this.heads()));
|
|
24
|
+
}
|
|
25
|
+
schedule() {
|
|
26
|
+
if (this.pending)
|
|
27
|
+
return;
|
|
28
|
+
this.pending = setTimeout(async () => {
|
|
29
|
+
this.pending = null;
|
|
30
|
+
if (!this.clients.size)
|
|
31
|
+
return;
|
|
32
|
+
const snapshot = await this.snapshot();
|
|
33
|
+
if (!snapshot)
|
|
34
|
+
return;
|
|
35
|
+
const msg = JSON.stringify(snapshot);
|
|
36
|
+
for (const c of this.clients)
|
|
37
|
+
if (c.readyState === c.OPEN)
|
|
38
|
+
c.send(msg);
|
|
39
|
+
}, 50);
|
|
40
|
+
}
|
|
41
|
+
// Each session's journal version, sent on its own: it changes with every line Claude writes, and the
|
|
42
|
+
// list is costly to read.
|
|
43
|
+
scheduleHeads() {
|
|
44
|
+
if (this.pendingHeads)
|
|
45
|
+
return;
|
|
46
|
+
this.pendingHeads = setTimeout(() => {
|
|
47
|
+
this.pendingHeads = null;
|
|
48
|
+
const msg = JSON.stringify(this.heads());
|
|
49
|
+
for (const c of this.clients)
|
|
50
|
+
if (c.readyState === c.OPEN)
|
|
51
|
+
c.send(msg);
|
|
52
|
+
}, 50);
|
|
53
|
+
}
|
|
54
|
+
heads() {
|
|
55
|
+
return { type: 'heads', machine: this.machine.id, epoch: this.machine.journal.epoch, heads: this.machine.journal.heads() };
|
|
56
|
+
}
|
|
57
|
+
// Null when tmux does not answer: the next change, or the machine's watch, brings a list that holds.
|
|
58
|
+
// asOf: when it was read, so a client can tell an answer about a message (dated by its `at`) the list
|
|
59
|
+
// already accounts for from one it does not. Also asked for on its own, by a client whose socket does
|
|
60
|
+
// not get through.
|
|
61
|
+
async snapshot() {
|
|
62
|
+
const asOf = Date.now();
|
|
63
|
+
const sessions = await this.machine.listSessions().catch(() => null);
|
|
64
|
+
if (!sessions)
|
|
65
|
+
return null;
|
|
66
|
+
const replies = await Promise.all(sessions.map((s) => this.lastReply(s).catch(() => null)));
|
|
67
|
+
return { type: 'sessions', machine: this.machine.id, asOf, sessions: sessions.map((s, i) => ({ ...s, lastReply: replies[i] })) };
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
if (this.pending)
|
|
71
|
+
clearTimeout(this.pending);
|
|
72
|
+
if (this.pendingHeads)
|
|
73
|
+
clearTimeout(this.pendingHeads);
|
|
74
|
+
for (const c of this.clients)
|
|
75
|
+
c.close(1001);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Conflict } from '../../system/errors.js';
|
|
2
|
+
// The Claude accounts on the Mac: the one new sessions run on, and each one's last limits.
|
|
3
|
+
export class Accounts {
|
|
4
|
+
d;
|
|
5
|
+
p;
|
|
6
|
+
constructor(d, p) {
|
|
7
|
+
this.d = d;
|
|
8
|
+
this.p = p;
|
|
9
|
+
}
|
|
10
|
+
// Written when a figure moves by a whole point or a reset time changes, not on every status line.
|
|
11
|
+
keepLimits(dir, u) {
|
|
12
|
+
const before = this.d.store.get().claudeLimits?.[dir];
|
|
13
|
+
const same = (a, b) => Math.round(a?.used ?? -1) === Math.round(b?.used ?? -1) && a?.resetsAt === b?.resetsAt;
|
|
14
|
+
if (before && same(before.fiveHour, u.fiveHour) && same(before.sevenDay, u.sevenDay))
|
|
15
|
+
return;
|
|
16
|
+
this.d.store.update((st) => {
|
|
17
|
+
st.claudeLimits = { ...st.claudeLimits, [dir]: { fiveHour: u.fiveHour, sevenDay: u.sevenDay, at: Date.now() } };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
// The account new sessions here run on: the one picked while it can still be, else this server's own.
|
|
21
|
+
// undefined: none was ever picked, and the start command says nothing of accounts.
|
|
22
|
+
async accountInUse() {
|
|
23
|
+
const picked = this.d.store.get().claudeAccount;
|
|
24
|
+
if (picked === undefined || !this.d.accounts)
|
|
25
|
+
return undefined;
|
|
26
|
+
const found = (await this.d.accounts.list()).find((a) => a.id === picked);
|
|
27
|
+
return found && this.d.accounts.pickable(found) ? picked : this.d.accounts.ownId();
|
|
28
|
+
}
|
|
29
|
+
async claudeAccountsView() {
|
|
30
|
+
const accounts = this.d.accounts ? await this.d.accounts.list() : [];
|
|
31
|
+
const limits = this.d.store.get().claudeLimits ?? {};
|
|
32
|
+
const inUse = await this.accountInUse();
|
|
33
|
+
return {
|
|
34
|
+
accounts: accounts.map((a) => ({ ...a, limits: limits[a.id] ?? null })),
|
|
35
|
+
inUse: inUse ?? this.d.accounts?.ownId() ?? '',
|
|
36
|
+
picked: this.d.store.get().claudeAccount !== undefined,
|
|
37
|
+
...this.p.moves.counts(inUse),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// The id is one of the accounts found, never taken as a path of its own.
|
|
41
|
+
async pickClaudeAccount(id) {
|
|
42
|
+
const found = this.d.accounts && (await this.d.accounts.list()).find((a) => a.id === id);
|
|
43
|
+
if (!found || !this.d.accounts?.pickable(found))
|
|
44
|
+
throw new Conflict('that Claude account cannot be used now', 'account-unusable');
|
|
45
|
+
this.d.store.update((st) => void (st.claudeAccount = id));
|
|
46
|
+
return this.claudeAccountsView();
|
|
47
|
+
}
|
|
48
|
+
// Which account a Claude session runs on, said only on a Mac with more than one.
|
|
49
|
+
accountLabel(meta) {
|
|
50
|
+
const known = this.d.accounts?.known() ?? [];
|
|
51
|
+
if (meta.kind !== 'claude' || meta.claudeDir === undefined || known.length < 2)
|
|
52
|
+
return undefined;
|
|
53
|
+
return known.find((a) => a.id === meta.claudeDir)?.email;
|
|
54
|
+
}
|
|
55
|
+
}
|