workoffdesk 1.0.1 → 1.0.2

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 (73) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +5 -3
  3. package/dist/build.json +1 -1
  4. package/dist/server/agents/claude/screen/background.js +7 -0
  5. package/dist/server/agents/claude/screen/prompt.js +34 -0
  6. package/dist/server/agents/claude/screen/screen.js +42 -3
  7. package/dist/server/agents/claude/terminals.js +67 -12
  8. package/dist/server/agents/claude/transcript/chat.js +98 -135
  9. package/dist/server/agents/claude/transcript/events.js +158 -0
  10. package/dist/server/agents/claude/transcript/transcriptFile.js +50 -10
  11. package/dist/server/agents/claude/transcript/transcripts.js +118 -30
  12. package/dist/server/agents/claude/transcript/turn.js +40 -61
  13. package/dist/server/agents/codex/agent.js +9 -2
  14. package/dist/server/agents/codex/events.js +107 -0
  15. package/dist/server/agents/codex/items.js +54 -74
  16. package/dist/server/agents/codex/notifications.js +14 -13
  17. package/dist/server/agents/codex/threads.js +54 -40
  18. package/dist/server/agents/events.js +4 -0
  19. package/dist/server/agents/turnSays.js +58 -0
  20. package/dist/server/cli.js +2 -0
  21. package/dist/server/commands/activity.js +28 -0
  22. package/dist/server/http/app.js +8 -4
  23. package/dist/server/http/auth/guard.js +8 -4
  24. package/dist/server/http/auth/passkey.js +6 -3
  25. package/dist/server/http/routes/appUpdate.js +1 -1
  26. package/dist/server/http/routes/chat.js +10 -0
  27. package/dist/server/http/routes/claude.js +70 -16
  28. package/dist/server/http/routes/codex.js +38 -3
  29. package/dist/server/http/routes/journal.js +3 -1
  30. package/dist/server/http/routes/push.js +36 -5
  31. package/dist/server/http/routes/pushRelay.js +62 -0
  32. package/dist/server/http/routes/sessions.js +32 -1
  33. package/dist/server/http/serve.js +8 -2
  34. package/dist/server/journal/db.js +4 -0
  35. package/dist/server/journal/index.js +4 -0
  36. package/dist/server/journal/ingest.js +26 -13
  37. package/dist/server/notify/apns.js +12 -7
  38. package/dist/server/notify/push.js +30 -4
  39. package/dist/server/notify/seal.js +12 -0
  40. package/dist/server/ops/doctor.js +7 -6
  41. package/dist/server/ops/problems.js +12 -7
  42. package/dist/server/sessions/control.js +65 -0
  43. package/dist/server/sessions/events.js +7 -5
  44. package/dist/server/sessions/machine/input.js +21 -2
  45. package/dist/server/sessions/machine/lifecycle.js +151 -81
  46. package/dist/server/sessions/machine/moves.js +65 -13
  47. package/dist/server/sessions/machine/naming.js +85 -0
  48. package/dist/server/sessions/machine/state.js +2 -0
  49. package/dist/server/sessions/machine/status.js +1 -2
  50. package/dist/server/sessions/machine/takeOvers.js +92 -0
  51. package/dist/server/sessions/machine.js +87 -23
  52. package/dist/server/sessions/outbox.js +7 -1
  53. package/dist/server/system/config.js +16 -1
  54. package/dist/server/system/errors.js +2 -2
  55. package/dist/server/system/messages.js +14 -176
  56. package/dist/server/system/messages.zh.js +186 -0
  57. package/dist/server/system/project.js +65 -0
  58. package/dist/server/system/store.js +1 -1
  59. package/dist/server/system/tmux.js +1 -1
  60. package/dist/web/assets/{Setup-C27bNf5I.js → Setup-OaIjxfwl.js} +1 -1
  61. package/dist/web/assets/main-DFndf3Z8.js +132 -0
  62. package/dist/web/assets/main-mChS_ic8.css +1 -0
  63. package/dist/web/icon-180.png +0 -0
  64. package/dist/web/icon-192.png +0 -0
  65. package/dist/web/icon-512.png +0 -0
  66. package/dist/web/icon-maskable-512.png +0 -0
  67. package/dist/web/icon.svg +14 -6
  68. package/dist/web/index.html +2 -2
  69. package/dist/web/manifest.webmanifest +1 -1
  70. package/dist/web/sw.js +4 -4
  71. package/package.json +3 -3
  72. package/dist/web/assets/main-C8GiLoJy.css +0 -1
  73. package/dist/web/assets/main-DQFM4yJU.js +0 -132
@@ -19,15 +19,17 @@ export function loadApnsKey(home) {
19
19
  catch {
20
20
  throw wrong('not JSON');
21
21
  }
22
- const { teamId, keyId, keyFile } = raw ?? {};
22
+ const { teamId, keyId, keyFile, relayTopic } = raw ?? {};
23
23
  if (typeof teamId !== 'string' || !teamId)
24
24
  throw wrong('"teamId" is missing (the Team ID in the Apple Developer account)');
25
25
  if (typeof keyId !== 'string' || !keyId)
26
26
  throw wrong('"keyId" is missing (the 10 characters in AuthKey_<keyId>.p8)');
27
27
  if (typeof keyFile !== 'string' || !existsSync(keyFile))
28
28
  throw wrong(`"keyFile" names no file: ${String(keyFile)}`);
29
+ if (relayTopic !== undefined && (typeof relayTopic !== 'string' || !/^[A-Za-z0-9.-]{1,155}$/.test(relayTopic)))
30
+ throw wrong(`"relayTopic" is not a bundle id: ${String(relayTopic)}`);
29
31
  try {
30
- return { teamId, keyId, key: createPrivateKey(readFileSync(keyFile)) };
32
+ return { teamId, keyId, key: createPrivateKey(readFileSync(keyFile)), ...(relayTopic ? { relayTopic } : {}) };
31
33
  }
32
34
  catch {
33
35
  throw wrong(`"keyFile" is not a .p8 key: ${keyFile}`);
@@ -43,6 +45,9 @@ export class Apns {
43
45
  this.post = post;
44
46
  this.now = now;
45
47
  }
48
+ get relayTopic() {
49
+ return this.key.relayTopic;
50
+ }
46
51
  // A device that has not answered yet is tried on the production service (TestFlight and the App Store),
47
52
  // then on the sandbox (builds from Xcode). host: the service it answered on.
48
53
  async send(device, payload, collapseId) {
@@ -62,18 +67,18 @@ export class Apns {
62
67
  res = await this.post(host, `/3/device/${device.token}`, headers, body);
63
68
  }
64
69
  catch {
65
- return { host: device.host, gone: false };
70
+ return { host: device.host, gone: false, sent: false };
66
71
  }
67
72
  if (res.status === 200)
68
- return { host, gone: false };
73
+ return { host, gone: false, sent: true };
69
74
  if (res.status === 410)
70
- return { host, gone: true };
75
+ return { host, gone: true, sent: false };
71
76
  if (res.status === 403 && /ProviderToken/.test(res.reason ?? ''))
72
77
  this.token = null;
73
78
  if (res.reason !== 'BadDeviceToken')
74
- return { host: device.host ?? host, gone: false };
79
+ return { host: device.host ?? host, gone: false, sent: false };
75
80
  }
76
- return { gone: true };
81
+ return { gone: true, sent: false };
77
82
  }
78
83
  bearer() {
79
84
  const t = this.now();
@@ -1,4 +1,5 @@
1
1
  import webpush from 'web-push';
2
+ import { seal, threadOf } from './seal.js';
2
3
  const COOLDOWN = 30_000;
3
4
  // The most of what Claude said a notification carries; the phone shows the first lines of it.
4
5
  const SAID_MAX = 300;
@@ -15,12 +16,15 @@ export function plainLine(said) {
15
16
  const chars = Array.from(text);
16
17
  return chars.length > SAID_MAX ? `${chars.slice(0, SAID_MAX - 1).join('')}…` : text;
17
18
  }
19
+ const RELAY_WAIT_MS = 10_000;
20
+ const relayFetch = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(RELAY_WAIT_MS) }).then((r) => r.status);
18
21
  export class Pusher {
19
22
  store;
20
23
  send;
21
24
  now;
22
25
  subject;
23
26
  apns;
27
+ relayPost;
24
28
  lastSent = new Map();
25
29
  visible = new Map();
26
30
  constructor(store, opts) {
@@ -29,6 +33,7 @@ export class Pusher {
29
33
  this.now = opts.now ?? Date.now;
30
34
  this.subject = opts.subject;
31
35
  this.apns = opts.apns;
36
+ this.relayPost = opts.relayPost ?? relayFetch;
32
37
  }
33
38
  // Whether the iPhone app can be sent notifications from here.
34
39
  get toPhones() {
@@ -48,7 +53,7 @@ export class Pusher {
48
53
  async notify(sessionId, title, event, said) {
49
54
  const { vapid, pushSubs, apnsDevices } = this.store.get();
50
55
  const browsers = vapid ? pushSubs : [];
51
- const phones = this.apns ? apnsDevices : [];
56
+ const phones = apnsDevices.filter((d) => (this.relayed(d) ? true : !!this.apns));
52
57
  if ((!browsers.length && !phones.length) || this.visible.has(sessionId))
53
58
  return;
54
59
  const t = this.now();
@@ -85,11 +90,21 @@ export class Pusher {
85
90
  });
86
91
  }
87
92
  }
93
+ // An App Store app's device goes through its relay, unless this server is that relay; any other, to Apple
94
+ // with this server's key.
95
+ relayed(d) {
96
+ return !!d.relay && this.apns?.relayTopic !== d.topic;
97
+ }
88
98
  async toApp(devices, sessionId, title, event, said) {
89
- const apns = this.apns;
90
- if (!devices.length || !apns)
99
+ if (!devices.length)
91
100
  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 }))));
101
+ const results = await Promise.all(devices.map(async (d) => {
102
+ const alert = alertOf(title, d.words[event], event, said);
103
+ if (this.relayed(d))
104
+ return { token: d.token, gone: await this.throughRelay(d, sessionId, alert) };
105
+ const { host, gone } = await this.apns.send(d, { aps: { alert, sound: 'default', 'thread-id': sessionId }, body: { sessionId } }, sessionId);
106
+ return { token: d.token, host, gone };
107
+ }));
93
108
  const gone = new Set(results.filter((r) => r.gone).map((r) => r.token));
94
109
  const learned = new Map(results.filter((r) => !r.gone && r.host).map((r) => [r.token, r.host]));
95
110
  const known = new Map(devices.map((d) => [d.token, d.host]));
@@ -99,6 +114,17 @@ export class Pusher {
99
114
  s.apnsDevices = s.apnsDevices.filter((d) => !gone.has(d.token)).map((d) => (learned.has(d.token) ? { ...d, host: learned.get(d.token) } : d));
100
115
  });
101
116
  }
117
+ // Whether the relay says the device is gone.
118
+ async throughRelay(d, sessionId, alert) {
119
+ const { url, key } = d.relay;
120
+ const body = { token: d.token, topic: d.topic, sealed: seal(key, { alert, sessionId }), thread: threadOf(key, sessionId), hidden: d.words.hidden ?? '' };
121
+ try {
122
+ return (await this.relayPost(`${url}/api/push/relay`, JSON.stringify(body))) === 410;
123
+ }
124
+ catch {
125
+ return false;
126
+ }
127
+ }
102
128
  }
103
129
  // The phone's words for the event say what happened, and what Claude said, what about. A finished turn's
104
130
  // reply says by itself that the turn is over.
@@ -0,0 +1,12 @@
1
+ import { createCipheriv, createHmac, randomBytes } from 'node:crypto';
2
+ // A notification for the App Store app goes through a relay that must not read it. It is sealed with the key
3
+ // the phone gave this server: AES-256-GCM, as nonce, ciphertext and tag one after another, which is how
4
+ // CryptoKit's AES.GCM.SealedBox(combined:) in the phone's notification extension reads it.
5
+ export function seal(keyB64, said) {
6
+ const nonce = randomBytes(12);
7
+ const c = createCipheriv('aes-256-gcm', Buffer.from(keyB64, 'base64'), nonce);
8
+ const text = Buffer.concat([c.update(JSON.stringify(said), 'utf8'), c.final()]);
9
+ return Buffer.concat([nonce, text, c.getAuthTag()]).toString('base64');
10
+ }
11
+ // The thread a session's notifications are grouped under, which the relay sees.
12
+ export const threadOf = (keyB64, sessionId) => createHmac('sha256', Buffer.from(keyB64, 'base64')).update(sessionId).digest('hex').slice(0, 16);
@@ -61,12 +61,13 @@ async function serviceChecks(d, doc, config) {
61
61
  ? fail('service', doc.otherVersion(local, service.version, d.version), doc.restartFix(config.serviceLabel))
62
62
  : pass('service', doc.running(local, service.version)),
63
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)));
64
+ for (const origin of [config.origin, ...config.extraOrigins]) {
65
+ if (LOCAL.test(origin)) {
66
+ checks.push(pass('origin', doc.localOnly(origin)));
67
+ continue;
68
+ }
69
+ const there = await d.fetch(`${origin}/healthz`);
70
+ checks.push(there.ok ? pass('origin', origin) : fail('origin', doc.notReached(origin), doc.originFix(origin, local)));
70
71
  }
71
72
  return checks;
72
73
  }
@@ -38,17 +38,22 @@ function parse(text) {
38
38
  return null;
39
39
  return { at: new Date(time).toISOString(), level: named, msg: typeof msg === 'string' ? msg : '', detail: JSON.stringify(rest).slice(0, DETAIL_MAX) };
40
40
  }
41
+ // The newest first, the same words at the same level said once: limit is of different problems.
41
42
  export function recentProblems(logFile, limit = 50) {
42
- const found = [];
43
+ const found = new Map();
43
44
  for (const file of [logFile, `${logFile}.1`]) {
44
45
  const lines = tail(file).split('\n');
45
- for (let i = lines.length - 1; i >= 0 && found.length < limit; i--) {
46
+ for (let i = lines.length - 1; i >= 0; i--) {
46
47
  const p = lines[i] && parse(lines[i]);
47
- if (p)
48
- found.push(p);
48
+ if (!p)
49
+ continue;
50
+ const key = `${p.level} ${p.msg}`;
51
+ const seen = found.get(key);
52
+ if (seen)
53
+ seen.count++;
54
+ else if (found.size < limit)
55
+ found.set(key, { ...p, count: 1 });
49
56
  }
50
- if (found.length >= limit)
51
- break;
52
57
  }
53
- return found;
58
+ return [...found.values()];
54
59
  }
@@ -15,6 +15,25 @@ function openSession(open, body) {
15
15
  return bad('kind must be claude or codex');
16
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
17
  }
18
+ // The working count as a line of JSON at once, then again each time it changes, until the client goes away.
19
+ function streamActivity(activity, res) {
20
+ res.writeHead(200, { 'content-type': 'application/x-ndjson' });
21
+ let last = -1;
22
+ const check = () => {
23
+ const working = activity.working();
24
+ if (working === last)
25
+ return;
26
+ last = working;
27
+ res.write(`${JSON.stringify({ working })}\n`);
28
+ };
29
+ check();
30
+ const unwatch = activity.onChange(check);
31
+ const timer = setInterval(check, activity.recheckMs ?? 2000).unref();
32
+ res.on('close', () => {
33
+ unwatch();
34
+ clearInterval(timer);
35
+ });
36
+ }
18
37
  export function startControlServer(sockPath, o) {
19
38
  const answered = (view) => view.then((body) => ({ status: 200, body }), (e) => {
20
39
  const r = errorReply(e);
@@ -46,6 +65,8 @@ export function startControlServer(sockPath, o) {
46
65
  res.writeHead(status, { 'content-type': 'application/json' });
47
66
  res.end(JSON.stringify(body));
48
67
  };
68
+ if (req.method === 'GET' && req.url === '/activity')
69
+ return streamActivity(o.activity, res);
49
70
  const route = routes[`${req.method} ${req.url}`];
50
71
  if (!route)
51
72
  return reply({ status: 404, body: { error: 'not found' } });
@@ -95,3 +116,47 @@ export async function askToOpen(sockPath, r) {
95
116
  export const askAccounts = (sockPath) => ask(sockPath, 'GET', '/accounts');
96
117
  export const askToPick = (sockPath, id) => ask(sockPath, 'PUT', '/account', { id });
97
118
  export const askToMove = (sockPath) => ask(sockPath, 'POST', '/account/move');
119
+ // Each working count the server sends, until stop() or the server ends the stream; done fails when no server
120
+ // answers.
121
+ const readCount = (line) => {
122
+ try {
123
+ const working = JSON.parse(line).working;
124
+ return typeof working === 'number' ? working : undefined;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ };
130
+ export function watchActivity(sockPath, onCount) {
131
+ let stopped = false;
132
+ let req;
133
+ const done = new Promise((resolve, reject) => {
134
+ req = request({ socketPath: sockPath, method: 'GET', path: '/activity' }, (res) => {
135
+ if (res.statusCode !== 200) {
136
+ res.resume();
137
+ return reject(new Error(`server answered ${res.statusCode}`));
138
+ }
139
+ let rest = '';
140
+ res.setEncoding('utf8');
141
+ res.on('data', (chunk) => {
142
+ const lines = (rest + chunk).split('\n');
143
+ rest = lines.pop();
144
+ for (const line of lines) {
145
+ const working = readCount(line);
146
+ if (working !== undefined)
147
+ onCount(working);
148
+ }
149
+ });
150
+ res.on('close', () => resolve());
151
+ });
152
+ req.on('error', (e) => (stopped ? resolve() : reject(e)));
153
+ req.end();
154
+ });
155
+ return {
156
+ done,
157
+ stop: () => {
158
+ stopped = true;
159
+ req?.destroy();
160
+ },
161
+ };
162
+ }
@@ -1,12 +1,12 @@
1
1
  export class EventHub {
2
2
  machine;
3
- lastReply;
3
+ lastSaid;
4
4
  clients = new Set();
5
5
  pending = null;
6
6
  pendingHeads = null;
7
- constructor(machine, lastReply = async () => null) {
7
+ constructor(machine, lastSaid = async () => ({ reply: null, at: null })) {
8
8
  this.machine = machine;
9
- this.lastReply = lastReply;
9
+ this.lastSaid = lastSaid;
10
10
  machine.onChange(() => this.schedule());
11
11
  machine.journal.onHead(() => this.scheduleHeads());
12
12
  }
@@ -63,8 +63,10 @@ export class EventHub {
63
63
  const sessions = await this.machine.listSessions().catch(() => null);
64
64
  if (!sessions)
65
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] })) };
66
+ const said = await Promise.all(sessions.map((s) => this.lastSaid(s).catch(() => null)));
67
+ // A session is as recent as its conversation; tmux sees only keys typed at a terminal.
68
+ const shown = sessions.map((s, i) => ({ ...s, lastActivity: said[i]?.at ?? s.lastActivity, lastReply: said[i]?.reply ?? null }));
69
+ return { type: 'sessions', machine: this.machine.id, asOf, sessions: shown };
68
70
  }
69
71
  close() {
70
72
  if (this.pending)
@@ -37,13 +37,13 @@ export class Input {
37
37
  // before its thread starts (trust this folder?).
38
38
  if (this.d.store.get().sessions[id]?.kind === 'codex') {
39
39
  const choices = this.beforeThread(id) ? codexChoices(await this.d.tmux.screen(tmuxName(id))) : null;
40
- const screen = { choices, panel: null, suggestion: null, mode: null };
40
+ const screen = { choices, panel: null, suggestion: null, mode: null, background: false };
41
41
  this.d.journal.screenAsIs(id, screen);
42
42
  return { ...screen, live: null, asks: this.d.codex?.asks(id) ?? [] };
43
43
  }
44
44
  if (Date.now() - (this.s.macLooks.get(id) ?? 0) >= MAC_LOOK_MS) {
45
45
  this.s.macLooks.set(id, Date.now());
46
- void this.p.lifecycle.openOnMac();
46
+ this.p.lifecycle.openOnMac().catch(() => { });
47
47
  }
48
48
  const { screen, compacting } = await this.d.claude.read(tmuxName(id), this.s.statuses.get(id) === 'working');
49
49
  this.p.status.compactingOnScreen(id, compacting);
@@ -69,6 +69,17 @@ export class Input {
69
69
  const pressed = await this.d.claude.tick(tmuxName(id), ticked, labels);
70
70
  return this.readAfter(id, pressed, (s) => s.choices?.options.map((o) => o.label).join('\n') !== labels.join('\n'));
71
71
  }
72
+ async toBackground(id) {
73
+ if (!ID.test(id))
74
+ throw new InvalidRequest('bad session id');
75
+ if (this.d.store.get().sessions[id]?.kind !== 'claude')
76
+ return false;
77
+ // Answered once the screen no longer offers it, so a second press finds nothing to send.
78
+ if (!(await this.d.claude.toBackground(tmuxName(id))))
79
+ return false;
80
+ await this.screenOnceShown(id, (s) => !s.background, () => null).catch(() => null);
81
+ return true;
82
+ }
72
83
  // The screen as it is once a choice was answered, or found changed, goes into the journal for the phone:
73
84
  // read now when nothing was pressed, else until the options pressed are gone.
74
85
  readAfter(id, pressed, gone) {
@@ -145,6 +156,8 @@ export class Input {
145
156
  if (!(await this.p.status.agentRuns(id)) || (await this.dialogOpen(id)))
146
157
  return false;
147
158
  await this.d.tmux.leaveCopyMode(tmuxName(id));
159
+ if (!(await this.promptCleared(id)))
160
+ return false;
148
161
  await this.d.tmux.sendRaw(tmuxName(id), `${command}\r`);
149
162
  return true;
150
163
  }
@@ -194,10 +207,16 @@ export class Input {
194
207
  const meta = this.d.store.get().sessions[id];
195
208
  return meta?.kind === 'codex' && !meta.codexThreadId;
196
209
  }
210
+ // What is already typed at Claude's prompt is put aside first: pasted after it, the two would go as one.
197
211
  async paste(id, text) {
198
212
  await this.d.tmux.leaveCopyMode(tmuxName(id));
213
+ if (!(await this.promptCleared(id)))
214
+ throw new Error('what is typed at the prompt would not go aside');
199
215
  await this.d.tmux.paste(tmuxName(id), text.replace(CONTROL, ''));
200
216
  }
217
+ promptCleared(id) {
218
+ return this.d.store.get().sessions[id]?.kind === 'claude' ? this.d.claude.clearPrompt(tmuxName(id)) : Promise.resolve(true);
219
+ }
201
220
  async fitForChat(id) {
202
221
  if (!ID.test(id))
203
222
  throw new InvalidRequest('bad session id');