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.
- package/CHANGELOG.md +21 -0
- package/README.md +5 -3
- package/dist/build.json +1 -1
- package/dist/server/agents/claude/screen/background.js +7 -0
- package/dist/server/agents/claude/screen/prompt.js +34 -0
- package/dist/server/agents/claude/screen/screen.js +42 -3
- package/dist/server/agents/claude/terminals.js +67 -12
- package/dist/server/agents/claude/transcript/chat.js +98 -135
- package/dist/server/agents/claude/transcript/events.js +158 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +50 -10
- package/dist/server/agents/claude/transcript/transcripts.js +118 -30
- package/dist/server/agents/claude/transcript/turn.js +40 -61
- package/dist/server/agents/codex/agent.js +9 -2
- package/dist/server/agents/codex/events.js +107 -0
- package/dist/server/agents/codex/items.js +54 -74
- package/dist/server/agents/codex/notifications.js +14 -13
- package/dist/server/agents/codex/threads.js +54 -40
- package/dist/server/agents/events.js +4 -0
- package/dist/server/agents/turnSays.js +58 -0
- package/dist/server/cli.js +2 -0
- package/dist/server/commands/activity.js +28 -0
- package/dist/server/http/app.js +8 -4
- package/dist/server/http/auth/guard.js +8 -4
- package/dist/server/http/auth/passkey.js +6 -3
- package/dist/server/http/routes/appUpdate.js +1 -1
- package/dist/server/http/routes/chat.js +10 -0
- package/dist/server/http/routes/claude.js +70 -16
- package/dist/server/http/routes/codex.js +38 -3
- package/dist/server/http/routes/journal.js +3 -1
- package/dist/server/http/routes/push.js +36 -5
- package/dist/server/http/routes/pushRelay.js +62 -0
- package/dist/server/http/routes/sessions.js +32 -1
- package/dist/server/http/serve.js +8 -2
- package/dist/server/journal/db.js +4 -0
- package/dist/server/journal/index.js +4 -0
- package/dist/server/journal/ingest.js +26 -13
- package/dist/server/notify/apns.js +12 -7
- package/dist/server/notify/push.js +30 -4
- package/dist/server/notify/seal.js +12 -0
- package/dist/server/ops/doctor.js +7 -6
- package/dist/server/ops/problems.js +12 -7
- package/dist/server/sessions/control.js +65 -0
- package/dist/server/sessions/events.js +7 -5
- package/dist/server/sessions/machine/input.js +21 -2
- package/dist/server/sessions/machine/lifecycle.js +151 -81
- package/dist/server/sessions/machine/moves.js +65 -13
- package/dist/server/sessions/machine/naming.js +85 -0
- package/dist/server/sessions/machine/state.js +2 -0
- package/dist/server/sessions/machine/status.js +1 -2
- package/dist/server/sessions/machine/takeOvers.js +92 -0
- package/dist/server/sessions/machine.js +87 -23
- package/dist/server/sessions/outbox.js +7 -1
- package/dist/server/system/config.js +16 -1
- package/dist/server/system/errors.js +2 -2
- package/dist/server/system/messages.js +14 -176
- package/dist/server/system/messages.zh.js +186 -0
- package/dist/server/system/project.js +65 -0
- package/dist/server/system/store.js +1 -1
- package/dist/server/system/tmux.js +1 -1
- package/dist/web/assets/{Setup-C27bNf5I.js → Setup-OaIjxfwl.js} +1 -1
- package/dist/web/assets/main-DFndf3Z8.js +132 -0
- package/dist/web/assets/main-mChS_ic8.css +1 -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-maskable-512.png +0 -0
- package/dist/web/icon.svg +14 -6
- package/dist/web/index.html +2 -2
- package/dist/web/manifest.webmanifest +1 -1
- package/dist/web/sw.js +4 -4
- package/package.json +3 -3
- package/dist/web/assets/main-C8GiLoJy.css +0 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
|
26
|
-
app
|
|
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
|
|
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 <=
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
|
93
|
+
let v;
|
|
93
94
|
try {
|
|
94
|
-
|
|
95
|
+
v = JSON.parse(line);
|
|
95
96
|
}
|
|
96
97
|
catch {
|
|
97
98
|
return;
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
122
|
-
// changed.
|
|
123
|
-
function inClaudesQueue(db, id,
|
|
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;
|