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
@@ -32,11 +32,12 @@ function deviceName(ua) {
32
32
  return 'Device';
33
33
  }
34
34
  export function registerPasskeyRoutes(app, ctx, deps) {
35
- const { store, origin, rpId } = deps;
35
+ const { store } = deps;
36
36
  const now = deps.now ?? Date.now;
37
37
  const challenges = new ChallengeStore(now);
38
38
  const expect = (purpose) => (c) => challenges.consume(c, purpose);
39
- app.post('/api/auth/passkey/register/options', async () => {
39
+ app.post('/api/auth/passkey/register/options', async (req) => {
40
+ const { rpId } = ctx.guard.siteOf(req);
40
41
  const user = store.get().user;
41
42
  const options = await generateRegistrationOptions({
42
43
  rpName: 'WorkOffDesk',
@@ -57,6 +58,7 @@ export function registerPasskeyRoutes(app, ctx, deps) {
57
58
  const response = req.body?.response;
58
59
  if (!response)
59
60
  return reply.code(400).send({ error: 'registration failed' });
61
+ const { origin, rpId } = ctx.guard.siteOf(req);
60
62
  let result;
61
63
  try {
62
64
  result = await verifyRegistrationResponse({
@@ -92,7 +94,7 @@ export function registerPasskeyRoutes(app, ctx, deps) {
92
94
  app.post('/api/auth/passkey/login/options', PUBLIC, async (req, reply) => {
93
95
  if (!ctx.guard.originOk(req))
94
96
  return reply.code(403).send({ error: 'bad origin' });
95
- const options = await generateAuthenticationOptions({ rpID: rpId, userVerification: 'required' });
97
+ const options = await generateAuthenticationOptions({ rpID: ctx.guard.siteOf(req).rpId, userVerification: 'required' });
96
98
  challenges.add(options.challenge, 'login');
97
99
  return options;
98
100
  });
@@ -107,6 +109,7 @@ export function registerPasskeyRoutes(app, ctx, deps) {
107
109
  };
108
110
  if (!passkey || !response)
109
111
  return fail();
112
+ const { origin, rpId } = ctx.guard.siteOf(req);
110
113
  let result;
111
114
  try {
112
115
  result = await verifyAuthenticationResponse({
@@ -64,7 +64,7 @@ export const register = (app, ctx) => {
64
64
  req.log.warn({ updateId: currentId }, 'app update failed to launch on a phone');
65
65
  }
66
66
  // Requests that went well are not logged; this is how one sees whether the phones took an update.
67
- const checked = (offered) => req.log.info({ runtime, platform: header('expo-platform'), running: header('expo-current-update-id'), offered }, 'app update check');
67
+ const checked = (offered) => req.log.info({ runtime, platform: header('expo-platform'), running: header('expo-current-update-id'), embedded: header('expo-embedded-update-id'), failed, offered }, 'app update check');
68
68
  if (!served || header('expo-platform') !== 'ios' || runtime !== served.build.runtimeVersion) {
69
69
  checked(null);
70
70
  return reply.code(204).headers(PROTOCOL).send();
@@ -108,6 +108,16 @@ export const register = (app, ctx) => {
108
108
  return reply.code(404).send({ error: 'no such session' });
109
109
  return { screen: await m.press(req.params.id, keys) };
110
110
  });
111
+ // Sends what Claude runs in the foreground to the background, so what waits in its queue goes in; sent false
112
+ // when the screen no longer offers it.
113
+ app.post('/api/m/:machine/sessions/:id/background', async (req, reply) => {
114
+ const m = machineOr404(req, reply);
115
+ if (!m)
116
+ return reply;
117
+ if (!(await m.exists(req.params.id)))
118
+ return reply.code(404).send({ error: 'no such session' });
119
+ return { sent: await m.toBackground(req.params.id) };
120
+ });
111
121
  // Closes the dialog on screen, and only it: what the ✕ under a dialog does.
112
122
  app.post('/api/m/:machine/sessions/:id/close', async (req, reply) => {
113
123
  const m = machineOr404(req, reply);
@@ -1,18 +1,37 @@
1
- import { findTranscript, readChat, readChatBefore } from '../../agents/claude/transcript/transcriptFile.js';
2
- import { heldInBackground, listProjectDirs, listRecentClaudeSessions } from '../../agents/claude/transcript/transcripts.js';
1
+ import { findSubagent, findTranscript, readChat, readChatBefore } from '../../agents/claude/transcript/transcriptFile.js';
2
+ import { heldInBackground, listAllClaudeSessions, listProjectDirs, listRecentClaudeSessions, oneClaudeSession } from '../../agents/claude/transcript/transcripts.js';
3
3
  import { isDir } from '../../system/dirs.js';
4
+ import { InvalidRequest, NotFound } from '../../system/errors.js';
4
5
  // The Claude conversations people had on this Mac lately, and where each is open now.
5
- export async function recentClaude(deps) {
6
+ // How old a look at the Mac's terminals the list may show: a page open on a conversation asks every few seconds.
7
+ const MAC_SEEN_MS = 10_000;
8
+ export function recentClaude(deps) {
9
+ return claudeRows(deps, (root) => listRecentClaudeSessions(root));
10
+ }
11
+ // Every Claude conversation Claude Code still keeps on this Mac, however old.
12
+ export function allClaude(deps) {
13
+ return claudeRows(deps, listAllClaudeSessions);
14
+ }
15
+ // One Claude conversation on this Mac, as the lists have it; null when Claude Code keeps none by that id.
16
+ export async function oneClaude(deps, sid) {
17
+ const [row] = await claudeRows(deps, async (root) => {
18
+ const r = await oneClaudeSession(root, sid);
19
+ return r ? [r] : [];
20
+ });
21
+ return row ?? null;
22
+ }
23
+ async function claudeRows(deps, list) {
6
24
  const { machine } = deps;
7
25
  if (!deps.claudeProjectsRoot)
8
26
  return [];
9
27
  const [recent, sessions, held, onMac] = await Promise.all([
10
- listRecentClaudeSessions(deps.claudeProjectsRoot),
28
+ list(deps.claudeProjectsRoot),
11
29
  machine.listSessions(),
12
30
  deps.claudeJobsRoot ? heldInBackground(deps.claudeJobsRoot) : () => false,
13
- machine.openOnMac(),
31
+ machine.openOnMac(MAC_SEEN_MS),
14
32
  ]);
15
- const open = new Map(sessions.filter((s) => s.claudeSessionId).map((s) => [s.claudeSessionId, s.id]));
33
+ // An ended session is gone on with by Continue, which, with the conversation open on the Mac, leads to taking it over.
34
+ const open = new Map(sessions.filter((s) => s.claudeSessionId && s.status !== 'ended').map((s) => [s.claudeSessionId, s.id]));
16
35
  return recent.map((r) => {
17
36
  const there = onMac.get(r.sessionId);
18
37
  return {
@@ -27,11 +46,39 @@ export async function recentClaude(deps) {
27
46
  };
28
47
  });
29
48
  }
49
+ // The conversation of the Claude on this Mac that sent a message, by its process, and the session here that
50
+ // goes on with it. The name it sent under is checked: a process gone since can be another Claude now.
51
+ export async function peerOf(deps, pid, name) {
52
+ const c = await deps.machine.macClaudeOf(pid);
53
+ if (!c || (c.name !== null && c.name !== name))
54
+ throw new NotFound('the Claude that sent this is no longer running on the Mac', 'peer-gone');
55
+ const held = (await deps.machine.listSessions()).find((s) => s.claudeSessionId === c.sessionId && s.status !== 'ended');
56
+ return { sid: c.sessionId, openIn: held?.id ?? null };
57
+ }
58
+ // A page of a transcript's chat, as a session's chat is paged: on from `from`, or back from `before`, both places
59
+ // in the transcript named `sid`; from its latest turns when the caller's copy is of another one.
60
+ async function chatPage(path, sid, q, reply, sidechain = false) {
61
+ const from = Number(q.from ?? 0);
62
+ const before = q.before === undefined ? null : Number(q.before);
63
+ if (!Number.isSafeInteger(from) || from < 0)
64
+ return reply.code(400).send({ error: 'bad from' });
65
+ if (before !== null && (!Number.isSafeInteger(before) || before < 0))
66
+ return reply.code(400).send({ error: 'bad before' });
67
+ if (q.sid !== sid)
68
+ return { sid, ...(await readChat(path, 0, { sidechain })) };
69
+ return { sid, ...(await (before === null ? readChat(path, from, { sidechain }) : readChatBefore(path, before, { sidechain }))) };
70
+ }
30
71
  // Claude sessions on this Mac to pick up again, and the folders worth starting one in.
31
72
  export const register = (app, ctx) => {
32
73
  const { deps } = ctx;
33
74
  const { machine } = deps;
34
75
  app.get('/api/claude/recent', () => recentClaude(deps));
76
+ app.get('/api/claude/peers/:pid', (req) => {
77
+ const pid = Number(req.params.pid);
78
+ if (!Number.isSafeInteger(pid) || pid <= 0)
79
+ throw new InvalidRequest('bad process id');
80
+ return peerOf(deps, pid, req.query.name ?? '');
81
+ });
35
82
  // Stops waiting to take a conversation over from the Mac.
36
83
  app.delete('/api/claude/sessions/:sid/take-over', async (req, reply) => machine.cancelTakeOver(req.params.sid) ? reply.code(204).send() : reply.code(404).send({ error: 'not waiting' }));
37
84
  // A Claude session's conversation, by its session id, to read before picking it up here; the same shape
@@ -41,15 +88,22 @@ export const register = (app, ctx) => {
41
88
  const path = deps.claudeProjectsRoot ? await findTranscript(deps.claudeProjectsRoot, sid) : null;
42
89
  if (!path)
43
90
  return reply.code(404).send({ error: 'no such conversation' });
44
- const from = Number(req.query.from ?? 0);
45
- const before = req.query.before === undefined ? null : Number(req.query.before);
46
- if (!Number.isSafeInteger(from) || from < 0)
47
- return reply.code(400).send({ error: 'bad from' });
48
- if (before !== null && (!Number.isSafeInteger(before) || before < 0))
49
- return reply.code(400).send({ error: 'bad before' });
50
- if (req.query.sid !== sid)
51
- return { sid, ...(await readChat(path, 0)) };
52
- return { sid, ...(await (before === null ? readChat(path, from) : readChatBefore(path, before))) };
91
+ return chatPage(path, sid, req.query, reply);
92
+ });
93
+ // A sub-agent the conversation started, by the call that started it: what it was started to do, and its own
94
+ // conversation, read the same way.
95
+ const subagent = (sid, tool) => (deps.claudeProjectsRoot ? findSubagent(deps.claudeProjectsRoot, sid, tool) : null);
96
+ app.get('/api/claude/sessions/:sid/agents/:tool', async (req, reply) => {
97
+ const found = await subagent(req.params.sid, req.params.tool);
98
+ if (!found)
99
+ return reply.code(404).send({ error: 'no such sub-agent' });
100
+ return { description: found.description };
101
+ });
102
+ app.get('/api/claude/sessions/:sid/agents/:tool/chat', async (req, reply) => {
103
+ const found = await subagent(req.params.sid, req.params.tool);
104
+ if (!found)
105
+ return reply.code(404).send({ error: 'no such sub-agent' });
106
+ return chatPage(found.path, req.params.tool, req.query, reply, true);
53
107
  });
54
108
  // The Claude accounts on this Mac, the one new sessions run on, and a pick of another.
55
109
  app.get('/api/claude/accounts', () => machine.claudeAccountsView());
@@ -65,5 +119,5 @@ export const register = (app, ctx) => {
65
119
  req.log.info({ moving: view.moving }, 'open sessions moving to the claude account in use');
66
120
  return view;
67
121
  });
68
- app.get('/api/claude/projects', async () => listProjectDirs(await recentClaude(deps), deps.pinnedDirs ?? []));
122
+ app.get('/api/claude/projects', async () => listProjectDirs((await recentClaude(deps)).filter((r) => !r.folderGone), deps.pinnedDirs ?? []));
69
123
  };
@@ -1,13 +1,19 @@
1
1
  import { isDir } from '../../system/dirs.js';
2
- import { recentClaude } from './claude.js';
2
+ import { allClaude, oneClaude, recentClaude } from './claude.js';
3
3
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
4
  // How many items a read of a Codex conversation gives at most: the latest, or those before a place.
5
5
  const PAGE = 400;
6
+ // The conversations on the Mac a page gives, unless asked for another number, and at most.
7
+ const PAGE_CONVERSATIONS = 50;
8
+ const MAX_CONVERSATIONS = 200;
6
9
  // The Codex conversations people had on this Mac, in the shape of the Claude ones: background is "open in
7
10
  // another Codex app", which cannot be gone on with here.
8
- async function recentCodex(deps) {
11
+ function recentCodex(deps) {
12
+ return codexRows(deps, () => deps.machine.codexOnMac());
13
+ }
14
+ async function codexRows(deps, list) {
9
15
  const { machine } = deps;
10
- const [threads, sessions] = await Promise.all([machine.codexOnMac().catch(() => []), machine.listSessions()]);
16
+ const [threads, sessions] = await Promise.all([list().catch(() => []), machine.listSessions()]);
11
17
  const open = new Map(sessions.filter((s) => s.codexThreadId).map((s) => [s.codexThreadId, s.id]));
12
18
  return threads.map((t) => ({
13
19
  sessionId: t.id,
@@ -32,6 +38,35 @@ export const register = (app, ctx) => {
32
38
  const [claude, codex] = await Promise.all([recentClaude(deps), recentCodex(deps)]);
33
39
  return [...claude, ...codex].sort((a, b) => b.lastActivity - a.lastActivity);
34
40
  });
41
+ // Every conversation people had on this Mac that its agent still keeps, newest first, a page at a time: at most
42
+ // limit of those last active before `before`, and whether there are more. q: in the title, folder or what was
43
+ // said last. One a session here holds, running or past, is in the list of sessions instead.
44
+ app.get('/api/conversations', async (req, reply) => {
45
+ const limit = req.query.limit === undefined ? PAGE_CONVERSATIONS : Number(req.query.limit);
46
+ const before = req.query.before === undefined ? Infinity : Number(req.query.before);
47
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_CONVERSATIONS)
48
+ return reply.code(400).send({ error: 'bad limit' });
49
+ if (Number.isNaN(before))
50
+ return reply.code(400).send({ error: 'bad before' });
51
+ const q = (req.query.q ?? '').trim().toLowerCase();
52
+ const [claude, codex, sessions] = await Promise.all([allClaude(deps), codexRows(deps, () => machine.allCodexOnMac()), machine.listSessions()]);
53
+ const held = new Set(sessions.flatMap((s) => [s.claudeSessionId, s.codexThreadId].filter((c) => c !== undefined)));
54
+ const found = (r) => [r.title, r.cwd, r.lastPrompt].some((t) => t?.toLowerCase().includes(q));
55
+ const rows = [...claude, ...codex]
56
+ .filter((r) => !r.automated && !held.has(r.sessionId) && r.lastActivity < before && found(r))
57
+ .sort((a, b) => b.lastActivity - a.lastActivity);
58
+ return { conversations: rows.slice(0, limit), more: rows.length > limit };
59
+ });
60
+ // One conversation, however old, as the lists have it: what its page shows and offers.
61
+ app.get('/api/conversations/:agent/:sid', async (req, reply) => {
62
+ const { agent, sid } = req.params;
63
+ const row = agent === 'claude'
64
+ ? await oneClaude(deps, sid)
65
+ : agent === 'codex' && UUID.test(sid)
66
+ ? ((await codexRows(deps, async () => [await machine.oneCodexOnMac(sid)].filter((t) => t !== null)))[0] ?? null)
67
+ : null;
68
+ return row ?? reply.code(404).send({ error: 'no such conversation' });
69
+ });
35
70
  // A thread's conversation, to read before picking it up here; the same shape and paging as a Claude one's,
36
71
  // with an item's place in the thread for a byte's in the transcript.
37
72
  app.get('/api/codex/threads/:id/chat', async (req, reply) => {
@@ -12,6 +12,8 @@ export const register = (app, ctx) => {
12
12
  const v = count(req.query.v);
13
13
  if (!Number.isSafeInteger(v))
14
14
  return reply.code(400).send({ error: 'bad version' });
15
+ if (machine.isPast(req.params.id))
16
+ return machine.journal.sync(req.params.id, req.query.epoch, v);
15
17
  if (!(await m.exists(req.params.id)))
16
18
  return reply.code(404).send({ error: 'no such session' });
17
19
  // An answer is never behind the transcript, nor the screen (a copy that asks has no line reading it).
@@ -28,7 +30,7 @@ export const register = (app, ctx) => {
28
30
  const conv = req.query.conv ?? '';
29
31
  if (!Number.isSafeInteger(before) || !CONV.test(conv))
30
32
  return reply.code(400).send({ error: 'conv and before required' });
31
- if (!(await m.exists(req.params.id)))
33
+ if (!machine.isPast(req.params.id) && !(await m.exists(req.params.id)))
32
34
  return reply.code(404).send({ error: 'no such session' });
33
35
  return machine.journal.older(req.params.id, conv, before);
34
36
  });
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  // The phones that get a notification when Claude needs an answer.
2
3
  export const register = (app, ctx) => {
3
4
  const { deps, now } = ctx;
@@ -22,8 +23,9 @@ export const register = (app, ctx) => {
22
23
  });
23
24
  return reply.code(204).send();
24
25
  });
25
- // The iPhone app: ready says whether this server holds a key to reach it.
26
- app.get('/api/push/apns', async () => ({ ready: !!pusher?.toPhones }));
26
+ // The iPhone app: ready says whether this server can reach it, with its own key or through the relay the
27
+ // app names (relay=1).
28
+ app.get('/api/push/apns', async (req) => ({ ready: req.query.relay === '1' || !!pusher?.toPhones }));
27
29
  app.post('/api/push/apns', async (req, reply) => {
28
30
  const d = parseDevice(req.body);
29
31
  if (!d)
@@ -31,7 +33,7 @@ export const register = (app, ctx) => {
31
33
  // A phone tells its token on every start and whenever iOS hands it out: what is known already is not
32
34
  // written again.
33
35
  const was = store.get().apnsDevices.find((x) => x.token === d.token);
34
- if (was && was.topic === d.topic && was.words.waiting === d.words.waiting && was.words.idle === d.words.idle)
36
+ if (was && isDeepStrictEqual({ topic: was.topic, words: was.words, relay: was.relay }, { topic: d.topic, words: d.words, relay: d.relay }))
35
37
  return reply.code(204).send();
36
38
  store.update((s) => {
37
39
  const was = s.apnsDevices.find((x) => x.token === d.token);
@@ -47,7 +49,7 @@ export const register = (app, ctx) => {
47
49
  return reply.code(204).send();
48
50
  });
49
51
  };
50
- const words = (v) => typeof v === 'string' && v.length > 0 && v.length <= 200;
52
+ const words = (v, max = 200) => typeof v === 'string' && v.length > 0 && v.length <= max;
51
53
  function parseDevice(body) {
52
54
  const b = body;
53
55
  if (!b || typeof b.token !== 'string' || !/^[0-9a-f]{64,200}$/i.test(b.token))
@@ -56,7 +58,36 @@ function parseDevice(body) {
56
58
  return null;
57
59
  if (!words(b.words?.waiting) || !words(b.words?.idle))
58
60
  return null;
59
- return { token: b.token, topic: b.topic, words: { waiting: b.words.waiting, idle: b.words.idle } };
61
+ const hidden = b.words?.hidden;
62
+ if (hidden !== undefined && !words(hidden, 120))
63
+ return null;
64
+ const relay = b.relay === undefined ? undefined : parseRelay(b.relay);
65
+ if (relay === null || (relay && hidden === undefined))
66
+ return null;
67
+ return {
68
+ token: b.token,
69
+ topic: b.topic,
70
+ words: { waiting: b.words.waiting, idle: b.words.idle, ...(hidden === undefined ? {} : { hidden: hidden }) },
71
+ ...(relay ? { relay } : {}),
72
+ };
73
+ }
74
+ // An https origin, and a 32-byte key in base64.
75
+ function parseRelay(v) {
76
+ const r = v;
77
+ if (!r || typeof r.url !== 'string' || typeof r.key !== 'string')
78
+ return null;
79
+ let url;
80
+ try {
81
+ url = new URL(r.url);
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ if (url.protocol !== 'https:' || url.origin !== r.url.replace(/\/$/, ''))
87
+ return null;
88
+ if (!/^[A-Za-z0-9+/]{43}=$/.test(r.key) || Buffer.from(r.key, 'base64').length !== 32)
89
+ return null;
90
+ return { url: url.origin, key: r.key };
60
91
  }
61
92
  function parseSubscription(body) {
62
93
  const b = body;
@@ -0,0 +1,62 @@
1
+ import { PUBLIC } from './context.js';
2
+ // On the server of whoever publishes the App Store app: other people's Macs hand it notifications for that
3
+ // app, sealed for the phone (notify/seal.ts), to pass on to Apple with this server's key. It needs no sign-in,
4
+ // as those Macs have none here; it cannot read what it passes on, and it delivers to no other app.
5
+ const MINUTE = 60_000;
6
+ const PER_DEVICE = 30;
7
+ const IN_ALL = 600;
8
+ // Apple takes 4 KB of payload; this leaves room for the rest of it.
9
+ const SEALED_MAX = 3000;
10
+ export const register = (relay) => (app, { now }) => {
11
+ const limit = minuteLimits(now);
12
+ app.post('/api/push/relay', { ...PUBLIC, bodyLimit: 8 * 1024 }, async (req, reply) => {
13
+ const r = parse(req.body);
14
+ if (!r)
15
+ return reply.code(400).send({ error: 'invalid notification' });
16
+ if (r.topic !== relay.relayTopic)
17
+ return reply.code(403).send({ error: 'not an app this server relays for' });
18
+ const wait = limit(r.token);
19
+ if (wait)
20
+ return reply.code(429).header('retry-after', String(wait)).send({ error: 'too many' });
21
+ const payload = { aps: { alert: { body: r.hidden }, sound: 'default', 'mutable-content': 1, 'thread-id': r.thread }, sealed: r.sealed };
22
+ const { gone, sent } = await relay.send({ token: r.token, topic: r.topic }, payload, r.thread);
23
+ if (gone)
24
+ return reply.code(410).send({ error: 'device gone' });
25
+ return sent ? reply.code(200).send({}) : reply.code(502).send({ error: 'not taken' });
26
+ });
27
+ };
28
+ function parse(body) {
29
+ const b = body;
30
+ if (!b || typeof b.token !== 'string' || !/^[0-9a-f]{64}$/i.test(b.token))
31
+ return null;
32
+ if (typeof b.topic !== 'string')
33
+ return null;
34
+ if (typeof b.sealed !== 'string' || b.sealed.length > SEALED_MAX || !/^[A-Za-z0-9+/]+={0,2}$/.test(b.sealed))
35
+ return null;
36
+ if (typeof b.thread !== 'string' || !/^[0-9a-f]{16}$/.test(b.thread))
37
+ return null;
38
+ if (typeof b.hidden !== 'string' || !b.hidden || b.hidden.length > 120)
39
+ return null;
40
+ return { token: b.token, topic: b.topic, sealed: b.sealed, thread: b.thread, hidden: b.hidden };
41
+ }
42
+ // How many a minute one device, and all of them, may be sent; the seconds to wait when over, else 0.
43
+ function minuteLimits(now) {
44
+ let minute = -1;
45
+ let all = 0;
46
+ const each = new Map();
47
+ return (token) => {
48
+ const t = now();
49
+ const m = Math.floor(t / MINUTE);
50
+ if (m !== minute) {
51
+ minute = m;
52
+ all = 0;
53
+ each.clear();
54
+ }
55
+ const mine = each.get(token) ?? 0;
56
+ if (mine >= PER_DEVICE || all >= IN_ALL)
57
+ return Math.ceil(((m + 1) * MINUTE - t) / 1000);
58
+ each.set(token, mine + 1);
59
+ all++;
60
+ return 0;
61
+ };
62
+ }
@@ -39,6 +39,22 @@ export const register = (app, ctx) => {
39
39
  }
40
40
  return m.continueSession(req.params.id);
41
41
  });
42
+ // Moves the session to the Claude account in use, answering once it runs there; once it is free, at once.
43
+ app.post('/api/m/:machine/sessions/:id/move', async (req, reply) => {
44
+ const when = req.body?.when;
45
+ if (when !== undefined && when !== 'now' && when !== 'free')
46
+ return reply.code(400).send({ error: 'when must be now or free' });
47
+ const m = machineOr404(req, reply);
48
+ return m ? m.moveThis(req.params.id, when) : reply;
49
+ });
50
+ // Quits the Claudes in terminals on the Mac that hold the session's conversation too, leaving it to the session.
51
+ app.post('/api/m/:machine/sessions/:id/quit-on-mac', async (req, reply) => {
52
+ const m = machineOr404(req, reply);
53
+ if (!m)
54
+ return reply;
55
+ await m.quitOnMac(req.params.id, req.body?.force === true);
56
+ return reply.code(204).send();
57
+ });
42
58
  app.get('/api/m/:machine/agents', async (req, reply) => {
43
59
  const m = machineOr404(req, reply);
44
60
  return m ? m.agents : reply;
@@ -55,7 +71,22 @@ export const register = (app, ctx) => {
55
71
  await m.killSession(req.params.id);
56
72
  return reply.code(204).send();
57
73
  });
58
- // A name of the session's own, for every device; an empty one goes back to the directory's name.
74
+ // Pinned to the top of the list for every device, or unpinned; asking twice is asking once.
75
+ for (const method of ['PUT', 'DELETE']) {
76
+ app.route({
77
+ method,
78
+ url: '/api/m/:machine/sessions/:id/pin',
79
+ handler: async (req, reply) => {
80
+ const m = machineOr404(req, reply);
81
+ if (!m)
82
+ return reply;
83
+ if (!(await m.pinSession(req.params.id, method === 'PUT')))
84
+ return reply.code(404).send({ error: 'no such session' });
85
+ return reply.code(204).send();
86
+ },
87
+ });
88
+ }
89
+ // A name of the session's own, for every device and for its agent's conversation; never an empty one.
59
90
  app.patch('/api/m/:machine/sessions/:id', async (req, reply) => {
60
91
  const m = machineOr404(req, reply);
61
92
  if (!m)
@@ -73,7 +73,8 @@ export async function startServer(o) {
73
73
  // Push services want a contact URL; Apple rejects anything but https: or mailto:.
74
74
  const subject = o.config.origin.startsWith('https://') ? o.config.origin : 'mailto:workoffdesk@localhost';
75
75
  const apnsKey = loadApnsKey(o.home);
76
- const pusher = new Pusher(store, { subject, send: o.pushSend, apns: apnsKey ? new Apns(apnsKey, o.apnsPost) : undefined });
76
+ const apns = apnsKey ? new Apns(apnsKey, o.apnsPost) : undefined;
77
+ const pusher = new Pusher(store, { subject, send: o.pushSend, apns });
77
78
  machine.setNotifier(pusher);
78
79
  const hooks = await startHookServer(hookSock, createHookHandler(machine, pusher), (id, payload) => machine.setUsage(id, usageFrom(payload)), createPermissionHandler(machine, pusher));
79
80
  const control = await startControlServer(socketPath(o.home, 'control.sock'), {
@@ -91,6 +92,7 @@ export async function startServer(o) {
91
92
  return view;
92
93
  },
93
94
  },
95
+ activity: { working: () => machine.working(), onChange: (cb) => machine.onChange(cb) },
94
96
  });
95
97
  const stopWatch = machine.watch(o.watchMs ?? 2000);
96
98
  const { app } = await buildApp({
@@ -99,9 +101,11 @@ export async function startServer(o) {
99
101
  machine,
100
102
  origin: o.config.origin,
101
103
  rpId: o.config.rpId,
104
+ extraOrigins: o.config.extraOrigins,
102
105
  auditPath: join(o.home, 'logs/audit.log'),
103
106
  webDir: o.webDir,
104
107
  pusher,
108
+ relay: apns,
105
109
  claudeProjectsRoot: o.claudeProjectsRoot ?? join(homedir(), '.claude/projects'),
106
110
  claudeJobsRoot: o.claudeJobsRoot ?? join(homedir(), '.claude/jobs'),
107
111
  claudeDir: o.claudeDir ?? join(homedir(), '.claude'),
@@ -110,7 +114,7 @@ export async function startServer(o) {
110
114
  media: { uploadsDir: join(o.home, 'uploads') },
111
115
  appUpdate: o.appUpdate && { ...o.appUpdate, bundlesDir: join(o.home, 'app-bundles') },
112
116
  ops: o.ops,
113
- extend: (a, ctx) => registerPasskeyRoutes(a, ctx, { store, origin: o.config.origin, rpId: o.config.rpId }),
117
+ extend: (a, ctx) => registerPasskeyRoutes(a, ctx, { store }),
114
118
  });
115
119
  return {
116
120
  app,
@@ -123,6 +127,8 @@ export async function startServer(o) {
123
127
  // Prompts held for the phone go back to the Mac's dialog alone.
124
128
  hooks.closeAllConnections();
125
129
  control.close();
130
+ // The menu bar app's watch ends, and it asks again of the next server.
131
+ control.closeAllConnections();
126
132
  machine.closeJournal();
127
133
  },
128
134
  };
@@ -74,6 +74,7 @@ export class JournalDb {
74
74
  allStates: p('SELECT name, v, body FROM state WHERE session = ? ORDER BY v'),
75
75
  itemsBefore: p('SELECT id, conv, pos, v, body FROM item WHERE session = ? AND conv = ? AND pos < ? ORDER BY pos DESC LIMIT ?'),
76
76
  newestFirst: p('SELECT body FROM item WHERE session = ? AND conv = ? ORDER BY pos DESC'),
77
+ itemCount: p('SELECT COUNT(*) AS n FROM item WHERE session = ?'),
77
78
  dropItems: p('DELETE FROM item WHERE session = ?'),
78
79
  dropMessages: p('DELETE FROM message WHERE session = ?'),
79
80
  dropStates: p('DELETE FROM state WHERE session = ?'),
@@ -207,6 +208,9 @@ export class JournalDb {
207
208
  heads() {
208
209
  return Object.fromEntries(this.q.heads.all().map((r) => [r.id, r.v]));
209
210
  }
211
+ itemCount(id) {
212
+ return this.q.itemCount.get(id).n;
213
+ }
210
214
  drop(id) {
211
215
  this.tx(() => {
212
216
  for (const q of [this.q.dropItems, this.q.dropMessages, this.q.dropStates, this.q.dropSession])
@@ -179,6 +179,10 @@ export class Journal {
179
179
  this.headListeners.add(cb);
180
180
  return () => this.headListeners.delete(cb);
181
181
  }
182
+ // How many items of what was said the session has.
183
+ itemCount(id) {
184
+ return this.db.itemCount(id);
185
+ }
182
186
  drop(id) {
183
187
  this.db.drop(id);
184
188
  this.screenLive.delete(id);
@@ -1,6 +1,7 @@
1
1
  import { sentWords } from '../agents/claude/transcript/chat.js';
2
+ import { eventOf } from '../agents/claude/transcript/events.js';
2
3
  import { findTranscript, idItems, linesFrom } from '../agents/claude/transcript/transcriptFile.js';
3
- import { heldInTranscript } from '../agents/claude/transcript/turn.js';
4
+ import { queueInTranscript } from '../agents/claude/transcript/turn.js';
4
5
  import { backgroundRunning, liveShown, taskList } from './derive.js';
5
6
  // The transcript records a prompt when Claude Code takes it, after it was pasted.
6
7
  const LANDS_AFTER_MS = -2000;
@@ -73,7 +74,7 @@ export async function ingest(db, id, conv, o) {
73
74
  if (items)
74
75
  db.putState(id, 'live', { text: liveShown(o.live(true), db.turnTexts(id, conv)) });
75
76
  if (queueMoved)
76
- wrote = inClaudesQueue(db, id, heldInTranscript(path)) || wrote;
77
+ wrote = inClaudesQueue(db, id, queueInTranscript(path), o.log) || wrote;
77
78
  db.setConv(id, conv, read.next);
78
79
  wrote ||= items;
79
80
  });
@@ -89,17 +90,20 @@ export async function ingest(db, id, conv, o) {
89
90
  function titleIn(line, found) {
90
91
  if (!line.includes('-title"'))
91
92
  return;
92
- let r;
93
+ let v;
93
94
  try {
94
- r = JSON.parse(line);
95
+ v = JSON.parse(line);
95
96
  }
96
97
  catch {
97
98
  return;
98
99
  }
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;
100
+ const e = eventOf(v);
101
+ if (e.k !== 'claude:title' || !e.text.trim())
102
+ return;
103
+ if (e.by === 'person')
104
+ found.custom = e.text;
105
+ else
106
+ found.ai = e.text;
103
107
  }
104
108
  // Lands the messages not landed that the conversation already has, each at the first prompt with its words
105
109
  // that no other message is: the prompt was read before the message was known to be on its way.
@@ -118,15 +122,24 @@ export function landFound(db, id, log) {
118
122
  }
119
123
  // A message on its way whose words Claude holds in its own queue is enqueued, the oldest first for words held
120
124
  // 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
+ // lands, or is not found when the turn is over. One whose words were pulled back into the prompt at the Mac
126
+ // after it was sent, and are not held again, is taken back. True when any changed.
127
+ function inClaudesQueue(db, id, queue, log) {
128
+ const left = queue.held.map((t) => t.trim());
129
+ const pulled = queue.pulled.map((p) => ({ text: p.text.trim(), at: p.at }));
125
130
  let changed = false;
126
131
  for (const m of onTheirWay(db, id)) {
127
132
  const i = left.indexOf(pasted(m.text));
128
- if (i < 0)
133
+ if (i < 0) {
134
+ const p = pulled.findIndex((x) => x.text === pasted(m.text) && !(x.at - m.sentAt < LANDS_AFTER_MS));
135
+ if (p < 0)
136
+ continue;
137
+ pulled.splice(p, 1);
138
+ db.putMessage(id, { ...m, state: 'taken', ord: null });
139
+ log('message pulled back at the Mac', { session: id, qid: m.id });
140
+ changed = true;
129
141
  continue;
142
+ }
130
143
  left.splice(i, 1);
131
144
  if (m.state === 'enqueued')
132
145
  continue;