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,151 @@
|
|
|
1
|
+
import { sentWords } from '../agents/claude/transcript/chat.js';
|
|
2
|
+
import { findTranscript, idItems, linesFrom } from '../agents/claude/transcript/transcriptFile.js';
|
|
3
|
+
import { heldInTranscript } from '../agents/claude/transcript/turn.js';
|
|
4
|
+
import { backgroundRunning, liveShown, taskList } from './derive.js';
|
|
5
|
+
// The transcript records a prompt when Claude Code takes it, after it was pasted.
|
|
6
|
+
const LANDS_AFTER_MS = -2000;
|
|
7
|
+
const DERIVED = new Set(['task', 'todos', 'bg']);
|
|
8
|
+
// What a paste keeps of a message (Machine.paste): no control characters but line breaks and tabs.
|
|
9
|
+
const pasted = (text) => text.replace(/(?![\n\t])\p{Cc}/gu, '').trim();
|
|
10
|
+
const ON_THEIR_WAY = new Set(['delivering', 'delivered', 'enqueued']);
|
|
11
|
+
// The messages that went to Claude and are not known to have been taken in, oldest first.
|
|
12
|
+
export const onTheirWay = (db, id) => db
|
|
13
|
+
.messages(id)
|
|
14
|
+
.filter((m) => ON_THEIR_WAY.has(m.state))
|
|
15
|
+
.sort((a, b) => a.sentAt - b.sentAt);
|
|
16
|
+
// The messages a prompt in the transcript may be, oldest first: those on their way, and those not found when
|
|
17
|
+
// a turn ended, which Claude may take in after it (once a slow line brings it).
|
|
18
|
+
const notLanded = (db, id) => db
|
|
19
|
+
.messages(id)
|
|
20
|
+
.filter((m) => ON_THEIR_WAY.has(m.state) || m.state === 'unconfirmed')
|
|
21
|
+
.sort((a, b) => a.sentAt - b.sentAt);
|
|
22
|
+
// Takes the session's conversation into the journal from where it stopped, in transactions of whole lines,
|
|
23
|
+
// with what is worked out from it. True when anything was written.
|
|
24
|
+
export async function ingest(db, id, conv, o) {
|
|
25
|
+
if (!conv)
|
|
26
|
+
return false;
|
|
27
|
+
const path = await findTranscript(o.root, conv);
|
|
28
|
+
if (!path)
|
|
29
|
+
return false;
|
|
30
|
+
let wrote = false;
|
|
31
|
+
let changedConv = db.session(id)?.conv !== conv;
|
|
32
|
+
const fresh = changedConv;
|
|
33
|
+
const found = {};
|
|
34
|
+
if (changedConv) {
|
|
35
|
+
db.tx(() => {
|
|
36
|
+
db.setConv(id, conv, 0);
|
|
37
|
+
db.putState(id, 'conv', { conv });
|
|
38
|
+
});
|
|
39
|
+
wrote = true;
|
|
40
|
+
}
|
|
41
|
+
for (;;) {
|
|
42
|
+
const from = db.session(id)?.convRead ?? 0;
|
|
43
|
+
const read = await linesFrom(path, from);
|
|
44
|
+
// The conversation may have changed while the file was read; the next ingest takes the new one.
|
|
45
|
+
if (db.session(id)?.conv !== conv || db.session(id)?.convRead !== from)
|
|
46
|
+
return wrote;
|
|
47
|
+
const more = read.next < read.size && read.lines.length > 0;
|
|
48
|
+
db.tx(() => {
|
|
49
|
+
let pos = db.lastPos(id, conv);
|
|
50
|
+
let derived = changedConv;
|
|
51
|
+
let items = false;
|
|
52
|
+
let queueMoved = false;
|
|
53
|
+
const waiting = notLanded(db, id);
|
|
54
|
+
for (const { at, line } of read.lines) {
|
|
55
|
+
queueMoved ||= line.includes('"queue-operation"');
|
|
56
|
+
titleIn(line, found);
|
|
57
|
+
for (const { id: itemId, item } of idItems(line, at)) {
|
|
58
|
+
if (!db.putItem(id, conv, itemId, ++pos, item)) {
|
|
59
|
+
pos--;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
items = true;
|
|
63
|
+
if (DERIVED.has(item.k))
|
|
64
|
+
derived = true;
|
|
65
|
+
land(db, id, waiting, item, itemId, o.log);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (derived) {
|
|
69
|
+
const all = db.itemsSince(id, conv, -1).map((r) => r.body);
|
|
70
|
+
db.putState(id, 'tasks', taskList(all));
|
|
71
|
+
db.putState(id, 'background', backgroundRunning(all));
|
|
72
|
+
}
|
|
73
|
+
if (items)
|
|
74
|
+
db.putState(id, 'live', { text: liveShown(o.live(true), db.turnTexts(id, conv)) });
|
|
75
|
+
if (queueMoved)
|
|
76
|
+
wrote = inClaudesQueue(db, id, heldInTranscript(path)) || wrote;
|
|
77
|
+
db.setConv(id, conv, read.next);
|
|
78
|
+
wrote ||= items;
|
|
79
|
+
});
|
|
80
|
+
changedConv = false;
|
|
81
|
+
if (more)
|
|
82
|
+
continue;
|
|
83
|
+
if (fresh || found.custom || found.ai)
|
|
84
|
+
o.titled?.(found, fresh);
|
|
85
|
+
return wrote;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// A record of a name the conversation was given, kept in found.
|
|
89
|
+
function titleIn(line, found) {
|
|
90
|
+
if (!line.includes('-title"'))
|
|
91
|
+
return;
|
|
92
|
+
let r;
|
|
93
|
+
try {
|
|
94
|
+
r = JSON.parse(line);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (r.type === 'custom-title' && typeof r.customTitle === 'string' && r.customTitle.trim())
|
|
100
|
+
found.custom = r.customTitle;
|
|
101
|
+
if (r.type === 'ai-title' && typeof r.aiTitle === 'string' && r.aiTitle.trim())
|
|
102
|
+
found.ai = r.aiTitle;
|
|
103
|
+
}
|
|
104
|
+
// Lands the messages not landed that the conversation already has, each at the first prompt with its words
|
|
105
|
+
// that no other message is: the prompt was read before the message was known to be on its way.
|
|
106
|
+
export function landFound(db, id, log) {
|
|
107
|
+
const conv = db.session(id)?.conv;
|
|
108
|
+
const waiting = notLanded(db, id);
|
|
109
|
+
if (!conv || !waiting.length)
|
|
110
|
+
return;
|
|
111
|
+
const taken = new Set(db.messages(id).map((m) => m.item));
|
|
112
|
+
db.tx(() => {
|
|
113
|
+
for (const { id: itemId, body } of db.itemsSince(id, conv, -1)) {
|
|
114
|
+
if (!taken.has(itemId))
|
|
115
|
+
land(db, id, waiting, body, itemId, log);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// A message on its way whose words Claude holds in its own queue is enqueued, the oldest first for words held
|
|
120
|
+
// twice: one pasted while the status had not caught up with Claude being at work. One stays enqueued until it
|
|
121
|
+
// lands, or is not found when the turn is over (pulled back into the prompt at the Mac). True when any
|
|
122
|
+
// changed.
|
|
123
|
+
function inClaudesQueue(db, id, held) {
|
|
124
|
+
const left = held.map((t) => t.trim());
|
|
125
|
+
let changed = false;
|
|
126
|
+
for (const m of onTheirWay(db, id)) {
|
|
127
|
+
const i = left.indexOf(pasted(m.text));
|
|
128
|
+
if (i < 0)
|
|
129
|
+
continue;
|
|
130
|
+
left.splice(i, 1);
|
|
131
|
+
if (m.state === 'enqueued')
|
|
132
|
+
continue;
|
|
133
|
+
db.putMessage(id, { ...m, state: 'enqueued' });
|
|
134
|
+
changed = true;
|
|
135
|
+
}
|
|
136
|
+
return changed;
|
|
137
|
+
}
|
|
138
|
+
// The oldest message not known to be taken in whose words are the prompt's (or the command's) is it: it landed.
|
|
139
|
+
function land(db, id, waiting, item, itemId, log) {
|
|
140
|
+
const words = sentWords(item);
|
|
141
|
+
if (words === null)
|
|
142
|
+
return;
|
|
143
|
+
const at = 'at' in item && item.at ? Date.parse(item.at) : NaN;
|
|
144
|
+
const text = words.trim();
|
|
145
|
+
const n = waiting.findIndex((m) => pasted(m.text) === text && !(at - m.sentAt < LANDS_AFTER_MS));
|
|
146
|
+
if (n < 0)
|
|
147
|
+
return;
|
|
148
|
+
const [m] = waiting.splice(n, 1);
|
|
149
|
+
db.putMessage(id, { ...m, state: 'landed', item: itemId });
|
|
150
|
+
log('message landed', { session: id, qid: m.id, item: itemId, was: m.state, ms: Date.now() - m.sentAt });
|
|
151
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createPrivateKey, sign } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { connect } from 'node:http2';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
// Apple refuses a token older than an hour, and one renewed more often than every 20 minutes.
|
|
6
|
+
const TOKEN_LIFE = 50 * 60_000;
|
|
7
|
+
const ORIGINS = { production: 'https://api.push.apple.com', sandbox: 'https://api.sandbox.push.apple.com' };
|
|
8
|
+
export class ApnsConfigError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export function loadApnsKey(home) {
|
|
11
|
+
const file = join(home, 'apns.json');
|
|
12
|
+
if (!existsSync(file))
|
|
13
|
+
return null;
|
|
14
|
+
const wrong = (what) => new ApnsConfigError(`${file}: ${what}`);
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw wrong('not JSON');
|
|
21
|
+
}
|
|
22
|
+
const { teamId, keyId, keyFile } = raw ?? {};
|
|
23
|
+
if (typeof teamId !== 'string' || !teamId)
|
|
24
|
+
throw wrong('"teamId" is missing (the Team ID in the Apple Developer account)');
|
|
25
|
+
if (typeof keyId !== 'string' || !keyId)
|
|
26
|
+
throw wrong('"keyId" is missing (the 10 characters in AuthKey_<keyId>.p8)');
|
|
27
|
+
if (typeof keyFile !== 'string' || !existsSync(keyFile))
|
|
28
|
+
throw wrong(`"keyFile" names no file: ${String(keyFile)}`);
|
|
29
|
+
try {
|
|
30
|
+
return { teamId, keyId, key: createPrivateKey(readFileSync(keyFile)) };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw wrong(`"keyFile" is not a .p8 key: ${keyFile}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class Apns {
|
|
37
|
+
key;
|
|
38
|
+
post;
|
|
39
|
+
now;
|
|
40
|
+
token = null;
|
|
41
|
+
constructor(key, post = http2Post(), now = Date.now) {
|
|
42
|
+
this.key = key;
|
|
43
|
+
this.post = post;
|
|
44
|
+
this.now = now;
|
|
45
|
+
}
|
|
46
|
+
// A device that has not answered yet is tried on the production service (TestFlight and the App Store),
|
|
47
|
+
// then on the sandbox (builds from Xcode). host: the service it answered on.
|
|
48
|
+
async send(device, payload, collapseId) {
|
|
49
|
+
const hosts = device.host ? [device.host] : ['production', 'sandbox'];
|
|
50
|
+
const body = JSON.stringify(payload);
|
|
51
|
+
for (const host of hosts) {
|
|
52
|
+
const headers = {
|
|
53
|
+
authorization: `bearer ${this.bearer()}`,
|
|
54
|
+
'apns-topic': device.topic,
|
|
55
|
+
'apns-push-type': 'alert',
|
|
56
|
+
'apns-priority': '10',
|
|
57
|
+
'apns-expiration': String(Math.floor(this.now() / 1000) + 300),
|
|
58
|
+
'apns-collapse-id': collapseId,
|
|
59
|
+
};
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await this.post(host, `/3/device/${device.token}`, headers, body);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { host: device.host, gone: false };
|
|
66
|
+
}
|
|
67
|
+
if (res.status === 200)
|
|
68
|
+
return { host, gone: false };
|
|
69
|
+
if (res.status === 410)
|
|
70
|
+
return { host, gone: true };
|
|
71
|
+
if (res.status === 403 && /ProviderToken/.test(res.reason ?? ''))
|
|
72
|
+
this.token = null;
|
|
73
|
+
if (res.reason !== 'BadDeviceToken')
|
|
74
|
+
return { host: device.host ?? host, gone: false };
|
|
75
|
+
}
|
|
76
|
+
return { gone: true };
|
|
77
|
+
}
|
|
78
|
+
bearer() {
|
|
79
|
+
const t = this.now();
|
|
80
|
+
if (this.token && t - this.token.at < TOKEN_LIFE)
|
|
81
|
+
return this.token.value;
|
|
82
|
+
const part = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
83
|
+
const unsigned = `${part({ alg: 'ES256', kid: this.key.keyId })}.${part({ iss: this.key.teamId, iat: Math.floor(t / 1000) })}`;
|
|
84
|
+
const sig = sign('sha256', Buffer.from(unsigned), { key: this.key.key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
|
85
|
+
this.token = { value: `${unsigned}.${sig}`, at: t };
|
|
86
|
+
return this.token.value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function http2Post(origins = ORIGINS) {
|
|
90
|
+
const sessions = new Map();
|
|
91
|
+
const session = (host) => {
|
|
92
|
+
const open = sessions.get(host);
|
|
93
|
+
if (open && !open.closed && !open.destroyed)
|
|
94
|
+
return open;
|
|
95
|
+
const s = connect(origins[host]);
|
|
96
|
+
s.on('error', () => s.destroy());
|
|
97
|
+
s.on('close', () => sessions.get(host) === s && sessions.delete(host));
|
|
98
|
+
s.setTimeout(10 * 60_000, () => s.close());
|
|
99
|
+
s.unref();
|
|
100
|
+
sessions.set(host, s);
|
|
101
|
+
return s;
|
|
102
|
+
};
|
|
103
|
+
return (host, path, headers, body) => new Promise((resolve, reject) => {
|
|
104
|
+
const req = session(host).request({ ':method': 'POST', ':path': path, ...headers });
|
|
105
|
+
let status = 0;
|
|
106
|
+
let data = '';
|
|
107
|
+
req.setTimeout(15_000, () => req.close());
|
|
108
|
+
req.on('response', (h) => (status = Number(h[':status'])));
|
|
109
|
+
req.setEncoding('utf8');
|
|
110
|
+
req.on('data', (c) => (data += c));
|
|
111
|
+
req.on('error', reject);
|
|
112
|
+
req.on('close', () => {
|
|
113
|
+
if (!status)
|
|
114
|
+
return reject(new Error('no answer from APNs'));
|
|
115
|
+
let reason;
|
|
116
|
+
try {
|
|
117
|
+
reason = data ? JSON.parse(data).reason : undefined;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
reason = undefined;
|
|
121
|
+
}
|
|
122
|
+
resolve({ status, reason });
|
|
123
|
+
});
|
|
124
|
+
req.end(body);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import webpush from 'web-push';
|
|
2
|
+
const COOLDOWN = 30_000;
|
|
3
|
+
// The most of what Claude said a notification carries; the phone shows the first lines of it.
|
|
4
|
+
const SAID_MAX = 300;
|
|
5
|
+
// What Claude said, as a line of plain text: the marks of its markdown taken off, its start kept.
|
|
6
|
+
export function plainLine(said) {
|
|
7
|
+
const text = said
|
|
8
|
+
.replace(/^\s*```.*$/gm, '')
|
|
9
|
+
.replace(/^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/gm, '')
|
|
10
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
11
|
+
.replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+)/gm, '')
|
|
12
|
+
.replace(/\*\*|__|`|~~/g, '')
|
|
13
|
+
.replace(/\s+/g, ' ')
|
|
14
|
+
.trim();
|
|
15
|
+
const chars = Array.from(text);
|
|
16
|
+
return chars.length > SAID_MAX ? `${chars.slice(0, SAID_MAX - 1).join('')}…` : text;
|
|
17
|
+
}
|
|
18
|
+
export class Pusher {
|
|
19
|
+
store;
|
|
20
|
+
send;
|
|
21
|
+
now;
|
|
22
|
+
subject;
|
|
23
|
+
apns;
|
|
24
|
+
lastSent = new Map();
|
|
25
|
+
visible = new Map();
|
|
26
|
+
constructor(store, opts) {
|
|
27
|
+
this.store = store;
|
|
28
|
+
this.send = opts.send ?? ((sub, payload, o) => webpush.sendNotification(sub, payload, o));
|
|
29
|
+
this.now = opts.now ?? Date.now;
|
|
30
|
+
this.subject = opts.subject;
|
|
31
|
+
this.apns = opts.apns;
|
|
32
|
+
}
|
|
33
|
+
// Whether the iPhone app can be sent notifications from here.
|
|
34
|
+
get toPhones() {
|
|
35
|
+
return !!this.apns;
|
|
36
|
+
}
|
|
37
|
+
setVisible(sessionId, connId, isVisible) {
|
|
38
|
+
const conns = this.visible.get(sessionId) ?? new Set();
|
|
39
|
+
if (isVisible)
|
|
40
|
+
conns.add(connId);
|
|
41
|
+
else
|
|
42
|
+
conns.delete(connId);
|
|
43
|
+
if (conns.size)
|
|
44
|
+
this.visible.set(sessionId, conns);
|
|
45
|
+
else
|
|
46
|
+
this.visible.delete(sessionId);
|
|
47
|
+
}
|
|
48
|
+
async notify(sessionId, title, event, said) {
|
|
49
|
+
const { vapid, pushSubs, apnsDevices } = this.store.get();
|
|
50
|
+
const browsers = vapid ? pushSubs : [];
|
|
51
|
+
const phones = this.apns ? apnsDevices : [];
|
|
52
|
+
if ((!browsers.length && !phones.length) || this.visible.has(sessionId))
|
|
53
|
+
return;
|
|
54
|
+
const t = this.now();
|
|
55
|
+
if (t - (this.lastSent.get(sessionId) ?? -Infinity) < COOLDOWN)
|
|
56
|
+
return;
|
|
57
|
+
this.lastSent.set(sessionId, t);
|
|
58
|
+
const line = plainLine(said);
|
|
59
|
+
await Promise.all([this.toBrowsers(browsers, sessionId, title, event, line), this.toApp(phones, sessionId, title, event, line)]);
|
|
60
|
+
}
|
|
61
|
+
async toBrowsers(subs, sessionId, title, event, said) {
|
|
62
|
+
const vapid = this.store.get().vapid;
|
|
63
|
+
if (!subs.length || !vapid)
|
|
64
|
+
return;
|
|
65
|
+
const payload = JSON.stringify({ title, event, said, url: `/m/local/s/${sessionId}`, tag: sessionId });
|
|
66
|
+
const opts = {
|
|
67
|
+
vapidDetails: { subject: this.subject, publicKey: vapid.publicKey, privateKey: vapid.privateKey },
|
|
68
|
+
TTL: 300,
|
|
69
|
+
urgency: 'high',
|
|
70
|
+
};
|
|
71
|
+
const gone = [];
|
|
72
|
+
await Promise.all(subs.map(async (sub) => {
|
|
73
|
+
try {
|
|
74
|
+
await this.send(sub, payload, opts);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
const status = e?.statusCode;
|
|
78
|
+
if (status === 404 || status === 410)
|
|
79
|
+
gone.push(sub.endpoint);
|
|
80
|
+
}
|
|
81
|
+
}));
|
|
82
|
+
if (gone.length) {
|
|
83
|
+
this.store.update((s) => {
|
|
84
|
+
s.pushSubs = s.pushSubs.filter((x) => !gone.includes(x.endpoint));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async toApp(devices, sessionId, title, event, said) {
|
|
89
|
+
const apns = this.apns;
|
|
90
|
+
if (!devices.length || !apns)
|
|
91
|
+
return;
|
|
92
|
+
const results = await Promise.all(devices.map((d) => apns.send(d, { aps: { alert: alertOf(title, d.words[event], event, said), sound: 'default', 'thread-id': sessionId }, body: { sessionId } }, sessionId).then((r) => ({ token: d.token, ...r }))));
|
|
93
|
+
const gone = new Set(results.filter((r) => r.gone).map((r) => r.token));
|
|
94
|
+
const learned = new Map(results.filter((r) => !r.gone && r.host).map((r) => [r.token, r.host]));
|
|
95
|
+
const known = new Map(devices.map((d) => [d.token, d.host]));
|
|
96
|
+
if (!gone.size && [...learned].every(([token, host]) => known.get(token) === host))
|
|
97
|
+
return;
|
|
98
|
+
this.store.update((s) => {
|
|
99
|
+
s.apnsDevices = s.apnsDevices.filter((d) => !gone.has(d.token)).map((d) => (learned.has(d.token) ? { ...d, host: learned.get(d.token) } : d));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// The phone's words for the event say what happened, and what Claude said, what about. A finished turn's
|
|
104
|
+
// reply says by itself that the turn is over.
|
|
105
|
+
function alertOf(title, words, event, said) {
|
|
106
|
+
if (!said)
|
|
107
|
+
return { title, body: words };
|
|
108
|
+
return event === 'waiting' ? { title, subtitle: words, body: said } : { title, body: said };
|
|
109
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// A Mac that idle-sleeps takes its sessions off the phone. caffeinate -i holds off idle sleep only (the
|
|
2
|
+
// display still sleeps, a closed lid still sleeps the Mac), and -w ends it with the process it watches.
|
|
3
|
+
export function keepAwake(o) {
|
|
4
|
+
if (!o.enabled || o.platform !== 'darwin')
|
|
5
|
+
return undefined;
|
|
6
|
+
const child = o.spawn('caffeinate', ['-i', '-w', String(o.pid)], { stdio: 'ignore' });
|
|
7
|
+
child.on('error', (err) => o.log('caffeinate did not start; the Mac may idle-sleep', err));
|
|
8
|
+
child.unref();
|
|
9
|
+
return child;
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
// Which source a dist was built from. The build writes it last, into a dist it emptied first, so a build
|
|
5
|
+
// that failed leaves no stamp, and one left over from another version names that version.
|
|
6
|
+
export const BUILD_STAMP = 'dist/build.json';
|
|
7
|
+
export function writeBuildStamp(root, stamp) {
|
|
8
|
+
writeFileSync(join(root, BUILD_STAMP), JSON.stringify(stamp));
|
|
9
|
+
}
|
|
10
|
+
export function readBuildStamp(root) {
|
|
11
|
+
try {
|
|
12
|
+
const s = JSON.parse(readFileSync(join(root, BUILD_STAMP), 'utf8'));
|
|
13
|
+
if (typeof s?.version !== 'string')
|
|
14
|
+
return null;
|
|
15
|
+
return { version: s.version, commit: typeof s.commit === 'string' ? s.commit : null, dirty: s.dirty === true };
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const versionIn = (root) => String(JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version);
|
|
22
|
+
// The build's last step. A tree that cannot say it is clean counts as having changes.
|
|
23
|
+
export function stampBuild(root) {
|
|
24
|
+
const version = versionIn(root);
|
|
25
|
+
const git = (...args) => {
|
|
26
|
+
const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
|
|
27
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
28
|
+
};
|
|
29
|
+
const commit = git('rev-parse', 'HEAD');
|
|
30
|
+
const stamp = { version, commit, dirty: commit !== null && git('status', '--porcelain') !== '' };
|
|
31
|
+
writeBuildStamp(root, stamp);
|
|
32
|
+
return stamp;
|
|
33
|
+
}
|
|
34
|
+
// A dist that was not built for the version its package.json names, which deploy and pack refuse: one
|
|
35
|
+
// left over from another version, or from a build that did not finish.
|
|
36
|
+
export function staleBuild(root) {
|
|
37
|
+
const version = versionIn(root);
|
|
38
|
+
const built = readBuildStamp(root)?.version ?? null;
|
|
39
|
+
return built === version ? null : { version, built };
|
|
40
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ApnsConfigError, loadApnsKey } from '../notify/apns.js';
|
|
4
|
+
import { ConfigError, loadConfig } from '../system/config.js';
|
|
5
|
+
import { MIN_NODE, nodeTooOld } from '../system/node.js';
|
|
6
|
+
import { m } from '../system/messages.js';
|
|
7
|
+
import { Store } from '../system/store.js';
|
|
8
|
+
const MIN_TMUX = [3, 3];
|
|
9
|
+
const LOCAL = /^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
|
10
|
+
const pass = (id, detail) => ({ id, ok: true, detail });
|
|
11
|
+
const fail = (id, detail, fix) => ({ id, ok: false, detail, fix });
|
|
12
|
+
function tmuxCheck(r, d) {
|
|
13
|
+
if (!r.ok)
|
|
14
|
+
return fail('tmux', d.notFound, d.tmuxFix);
|
|
15
|
+
const v = /^tmux (?:next-)?(\d+)\.(\d+)/.exec(r.out.trim());
|
|
16
|
+
const version = r.out.trim().replace(/^tmux /, '');
|
|
17
|
+
if (!v)
|
|
18
|
+
return fail('tmux', d.unknownVersion(version), d.tmuxFix);
|
|
19
|
+
const [major, minor] = [Number(v[1]), Number(v[2])];
|
|
20
|
+
if (major < MIN_TMUX[0] || (major === MIN_TMUX[0] && minor < MIN_TMUX[1]))
|
|
21
|
+
return fail('tmux', d.tooOld(version, MIN_TMUX.join('.')), d.tmuxFix);
|
|
22
|
+
return pass('tmux', version);
|
|
23
|
+
}
|
|
24
|
+
function setupCheck(home, doc) {
|
|
25
|
+
if (!existsSync(join(home, 'config.json')))
|
|
26
|
+
return { check: fail('setup', doc.notSetUp(home), doc.setupFix) };
|
|
27
|
+
let config;
|
|
28
|
+
try {
|
|
29
|
+
config = loadConfig(home);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
if (e instanceof ConfigError)
|
|
33
|
+
return { check: fail('setup', e.message, doc.setupFix) };
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
let user;
|
|
37
|
+
try {
|
|
38
|
+
user = new Store(join(home, 'state.json')).get().user?.username;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { check: fail('setup', e.message, doc.setupFix), config };
|
|
42
|
+
}
|
|
43
|
+
return { check: user ? pass('setup', doc.setUp(user, config.origin)) : fail('setup', doc.noUser, doc.setupFix), config };
|
|
44
|
+
}
|
|
45
|
+
// How to get a service that does not answer going, from what launchd has of it.
|
|
46
|
+
async function startFix(d, doc, label) {
|
|
47
|
+
const plist = join(d.launchAgents, `${label}.plist`);
|
|
48
|
+
if (!existsSync(plist))
|
|
49
|
+
return doc.installFix;
|
|
50
|
+
if (!(await d.run('launchctl', ['list', label])).ok)
|
|
51
|
+
return doc.bootstrapFix(plist);
|
|
52
|
+
return doc.kickstartFix(label, join(d.home, 'logs', 'server.log'));
|
|
53
|
+
}
|
|
54
|
+
async function serviceChecks(d, doc, config) {
|
|
55
|
+
const local = `http://127.0.0.1:${config.port}`;
|
|
56
|
+
const service = await d.fetch(`${local}/healthz`);
|
|
57
|
+
const checks = [
|
|
58
|
+
!service.ok
|
|
59
|
+
? fail('service', doc.notAnswering(local), await startFix(d, doc, config.serviceLabel))
|
|
60
|
+
: service.version && service.version !== d.version
|
|
61
|
+
? fail('service', doc.otherVersion(local, service.version, d.version), doc.restartFix(config.serviceLabel))
|
|
62
|
+
: pass('service', doc.running(local, service.version)),
|
|
63
|
+
];
|
|
64
|
+
if (LOCAL.test(config.origin)) {
|
|
65
|
+
checks.push(pass('origin', doc.localOnly(config.origin)));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const there = await d.fetch(`${config.origin}/healthz`);
|
|
69
|
+
checks.push(there.ok ? pass('origin', config.origin) : fail('origin', doc.notReached(config.origin), doc.originFix(config.origin, local)));
|
|
70
|
+
}
|
|
71
|
+
return checks;
|
|
72
|
+
}
|
|
73
|
+
function apnsCheck(home, doc) {
|
|
74
|
+
try {
|
|
75
|
+
const key = loadApnsKey(home);
|
|
76
|
+
return pass('apns', key ? doc.apnsKey(key.keyId) : doc.noApns);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e instanceof ApnsConfigError)
|
|
80
|
+
return fail('apns', e.message, doc.apnsFix(join(home, 'apns.json')));
|
|
81
|
+
throw e;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function runDoctor(d) {
|
|
85
|
+
const doc = (d.messages ?? m).doctor;
|
|
86
|
+
const [tmux, claude, codex] = await Promise.all([
|
|
87
|
+
d.run('tmux', ['-V']),
|
|
88
|
+
d.run(d.claudeBin, ['--version']),
|
|
89
|
+
d.codexBin ? d.run(d.codexBin, ['--version']) : Promise.resolve(null),
|
|
90
|
+
]);
|
|
91
|
+
const { check: setup, config } = setupCheck(d.home, doc);
|
|
92
|
+
return [
|
|
93
|
+
d.platform === 'darwin' ? pass('macos', d.platform) : fail('macos', d.platform, doc.macosOnly),
|
|
94
|
+
nodeTooOld(d.nodeVersion) ? fail('node', doc.tooOld(d.nodeVersion, MIN_NODE.join('.')), doc.nodeFix(MIN_NODE.join('.'))) : pass('node', d.nodeVersion),
|
|
95
|
+
tmuxCheck(tmux, doc),
|
|
96
|
+
claude.ok ? pass('claude', claude.out.trim()) : fail('claude', doc.notFound, doc.claudeFix),
|
|
97
|
+
pass('codex', codex?.ok ? codex.out.trim() : doc.noCodex),
|
|
98
|
+
setup,
|
|
99
|
+
// Without a config there is no port or address to ask.
|
|
100
|
+
...(config ? await serviceChecks(d, doc, config) : []),
|
|
101
|
+
...(config ? [config.preventSleep ? pass('sleep', doc.awake) : fail('sleep', doc.sleepsOff, doc.sleepFix(join(d.home, 'config.json')))] : []),
|
|
102
|
+
apnsCheck(d.home, doc),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
const WIDTH = 8;
|
|
106
|
+
export function formatChecks(checks) {
|
|
107
|
+
return checks
|
|
108
|
+
.map((c) => `${c.ok ? '✓' : '✗'} ${c.id.padEnd(WIDTH)}${c.detail}${c.fix ? `\n${' '.repeat(2 + WIDTH)}${c.fix}` : ''}`)
|
|
109
|
+
.join('\n');
|
|
110
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { writeJsonAtomic } from '../system/paths.js';
|
|
4
|
+
const fileOf = (home) => join(home, 'ops.json');
|
|
5
|
+
export function readLastOp(home) {
|
|
6
|
+
try {
|
|
7
|
+
const r = JSON.parse(readFileSync(fileOf(home), 'utf8'));
|
|
8
|
+
return typeof r?.op === 'string' && typeof r.pid === 'number' ? r : null;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const pidAlive = (pid) => {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return e.code === 'EPERM';
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export function opRunning(home, alive = pidAlive) {
|
|
24
|
+
const last = readLastOp(home);
|
|
25
|
+
return !!last && last.finishedAt === undefined && alive(last.pid);
|
|
26
|
+
}
|
|
27
|
+
export function beginOp(home, op, pid, now = Date.now()) {
|
|
28
|
+
writeJsonAtomic(fileOf(home), { op, pid, startedAt: now });
|
|
29
|
+
}
|
|
30
|
+
export function endOp(home, outcome, now = Date.now()) {
|
|
31
|
+
const last = readLastOp(home);
|
|
32
|
+
if (last)
|
|
33
|
+
writeJsonAtomic(fileOf(home), { ...last, finishedAt: now, ...outcome });
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
|
|
2
|
+
export const DETAIL_MAX = 4096;
|
|
3
|
+
// Enough of the end of a file for any number of problems shown, without reading 10 MB each time.
|
|
4
|
+
const TAIL_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const LEVELS = { 40: 'warn', 50: 'error', 60: 'fatal' };
|
|
6
|
+
function tail(file) {
|
|
7
|
+
let fd;
|
|
8
|
+
try {
|
|
9
|
+
fd = openSync(file, 'r');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const size = fstatSync(fd).size;
|
|
16
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
17
|
+
const buf = Buffer.alloc(length);
|
|
18
|
+
readSync(fd, buf, 0, length, size - length);
|
|
19
|
+
const text = buf.toString('utf8');
|
|
20
|
+
// A tail that starts mid-line drops that line.
|
|
21
|
+
return length < size ? text.slice(text.indexOf('\n') + 1) : text;
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function parse(text) {
|
|
28
|
+
let line;
|
|
29
|
+
try {
|
|
30
|
+
line = JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const { level, time, msg, pid: _pid, hostname: _host, ...rest } = line ?? {};
|
|
36
|
+
const named = typeof level === 'number' ? LEVELS[Math.min(60, Math.floor(level / 10) * 10)] : undefined;
|
|
37
|
+
if (!named || typeof time !== 'number')
|
|
38
|
+
return null;
|
|
39
|
+
return { at: new Date(time).toISOString(), level: named, msg: typeof msg === 'string' ? msg : '', detail: JSON.stringify(rest).slice(0, DETAIL_MAX) };
|
|
40
|
+
}
|
|
41
|
+
export function recentProblems(logFile, limit = 50) {
|
|
42
|
+
const found = [];
|
|
43
|
+
for (const file of [logFile, `${logFile}.1`]) {
|
|
44
|
+
const lines = tail(file).split('\n');
|
|
45
|
+
for (let i = lines.length - 1; i >= 0 && found.length < limit; i--) {
|
|
46
|
+
const p = lines[i] && parse(lines[i]);
|
|
47
|
+
if (p)
|
|
48
|
+
found.push(p);
|
|
49
|
+
}
|
|
50
|
+
if (found.length >= limit)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|