workoffdesk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, renameSync } from 'node:fs';
|
|
3
|
+
const SCHEMA = `
|
|
4
|
+
CREATE TABLE IF NOT EXISTS journal (epoch TEXT NOT NULL);
|
|
5
|
+
CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, v INTEGER NOT NULL, conv TEXT, conv_read INTEGER NOT NULL DEFAULT 0);
|
|
6
|
+
CREATE TABLE IF NOT EXISTS item (
|
|
7
|
+
session TEXT NOT NULL, id TEXT NOT NULL, conv TEXT NOT NULL, pos INTEGER NOT NULL, v INTEGER NOT NULL, body TEXT NOT NULL,
|
|
8
|
+
PRIMARY KEY (session, id)
|
|
9
|
+
);
|
|
10
|
+
CREATE INDEX IF NOT EXISTS item_by_pos ON item (session, conv, pos);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS item_by_v ON item (session, v);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS message (
|
|
13
|
+
session TEXT NOT NULL, id TEXT NOT NULL, v INTEGER NOT NULL, text TEXT NOT NULL, sent_at INTEGER NOT NULL,
|
|
14
|
+
state TEXT NOT NULL, ord REAL, item TEXT,
|
|
15
|
+
PRIMARY KEY (session, id)
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS message_by_v ON message (session, v);
|
|
18
|
+
CREATE TABLE IF NOT EXISTS state (session TEXT NOT NULL, name TEXT NOT NULL, v INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY (session, name));
|
|
19
|
+
CREATE INDEX IF NOT EXISTS state_by_v ON state (session, v);
|
|
20
|
+
`;
|
|
21
|
+
// SQLITE_CORRUPT and SQLITE_NOTADB: a file that cannot be read as a database at all.
|
|
22
|
+
const UNREADABLE = new Set([11, 26]);
|
|
23
|
+
// The item's id is the row's key: tool, result, task and bg items have an id of their own.
|
|
24
|
+
const itemRow = (r) => ({ t: 'item', key: r.id, v: r.v, body: { ...JSON.parse(r.body), pos: r.pos, conv: r.conv } });
|
|
25
|
+
const toMessage = (r) => ({ id: r.id, text: r.text, sentAt: r.sent_at, state: r.state, ord: r.ord, item: r.item });
|
|
26
|
+
const messageRow = (r) => ({ t: 'message', key: r.id, v: r.v, body: toMessage(r) });
|
|
27
|
+
const stateRow = (r) => ({ t: 'state', key: r.name, v: r.v, body: JSON.parse(r.body) });
|
|
28
|
+
export class JournalDb {
|
|
29
|
+
epoch;
|
|
30
|
+
db;
|
|
31
|
+
q;
|
|
32
|
+
depth = 0;
|
|
33
|
+
constructor(file, log = () => { }) {
|
|
34
|
+
let opened;
|
|
35
|
+
try {
|
|
36
|
+
opened = open(file);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (!UNREADABLE.has(e.errcode ?? -1))
|
|
40
|
+
throw e;
|
|
41
|
+
const aside = `${file}.broken-${Date.now()}`;
|
|
42
|
+
renameSync(file, aside);
|
|
43
|
+
for (const side of ['-wal', '-shm'])
|
|
44
|
+
if (existsSync(file + side))
|
|
45
|
+
renameSync(file + side, aside + side);
|
|
46
|
+
log('journal unreadable, set aside and started over', { aside, error: e.message });
|
|
47
|
+
opened = open(file);
|
|
48
|
+
}
|
|
49
|
+
this.db = opened.db;
|
|
50
|
+
this.epoch = opened.epoch;
|
|
51
|
+
const p = (sql) => this.db.prepare(sql);
|
|
52
|
+
this.q = {
|
|
53
|
+
session: p('SELECT v, conv, conv_read AS convRead FROM session WHERE id = ?'),
|
|
54
|
+
heads: p('SELECT id, v FROM session'),
|
|
55
|
+
setConv: p('INSERT INTO session (id, v, conv, conv_read) VALUES (?, 0, ?, ?) ON CONFLICT (id) DO UPDATE SET conv = excluded.conv, conv_read = excluded.conv_read'),
|
|
56
|
+
bump: p('INSERT INTO session (id, v) VALUES (?, 1) ON CONFLICT (id) DO UPDATE SET v = v + 1 RETURNING v'),
|
|
57
|
+
hasItem: p('SELECT 1 FROM item WHERE session = ? AND id = ?'),
|
|
58
|
+
putItem: p('INSERT INTO item (session, id, conv, pos, v, body) VALUES (?, ?, ?, ?, ?, ?)'),
|
|
59
|
+
lastPos: p('SELECT MAX(pos) AS pos FROM item WHERE session = ? AND conv = ?'),
|
|
60
|
+
itemsSince: p('SELECT id, body FROM item WHERE session = ? AND conv = ? AND pos > ? ORDER BY pos'),
|
|
61
|
+
putMessage: p(`INSERT INTO message (session, id, v, text, sent_at, state, ord, item) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT (session, id) DO UPDATE SET v = excluded.v, text = excluded.text, sent_at = excluded.sent_at,
|
|
63
|
+
state = excluded.state, ord = excluded.ord, item = excluded.item`),
|
|
64
|
+
messages: p('SELECT id, v, text, sent_at, state, ord, item FROM message WHERE session = ? ORDER BY v'),
|
|
65
|
+
message: p('SELECT id, v, text, sent_at, state, ord, item FROM message WHERE session = ? AND id = ?'),
|
|
66
|
+
inState: p('SELECT session, id, v, text, sent_at, state, ord, item FROM message WHERE state = ?'),
|
|
67
|
+
stateBody: p('SELECT body FROM state WHERE session = ? AND name = ?'),
|
|
68
|
+
withState: p('SELECT session FROM state WHERE name = ?'),
|
|
69
|
+
putState: p('INSERT INTO state (session, name, v, body) VALUES (?, ?, ?, ?) ON CONFLICT (session, name) DO UPDATE SET v = excluded.v, body = excluded.body'),
|
|
70
|
+
itemsAfter: p('SELECT id, conv, pos, v, body FROM item WHERE session = ? AND v > ? ORDER BY v LIMIT ?'),
|
|
71
|
+
messagesAfter: p('SELECT id, v, text, sent_at, state, ord, item FROM message WHERE session = ? AND v > ? ORDER BY v LIMIT ?'),
|
|
72
|
+
statesAfter: p('SELECT name, v, body FROM state WHERE session = ? AND v > ? ORDER BY v LIMIT ?'),
|
|
73
|
+
allMessages: p('SELECT id, v, text, sent_at, state, ord, item FROM message WHERE session = ? ORDER BY v'),
|
|
74
|
+
allStates: p('SELECT name, v, body FROM state WHERE session = ? ORDER BY v'),
|
|
75
|
+
itemsBefore: p('SELECT id, conv, pos, v, body FROM item WHERE session = ? AND conv = ? AND pos < ? ORDER BY pos DESC LIMIT ?'),
|
|
76
|
+
newestFirst: p('SELECT body FROM item WHERE session = ? AND conv = ? ORDER BY pos DESC'),
|
|
77
|
+
dropItems: p('DELETE FROM item WHERE session = ?'),
|
|
78
|
+
dropMessages: p('DELETE FROM message WHERE session = ?'),
|
|
79
|
+
dropStates: p('DELETE FROM state WHERE session = ?'),
|
|
80
|
+
dropSession: p('DELETE FROM session WHERE id = ?'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// One transaction, which a call made inside another joins. f must not wait on anything.
|
|
84
|
+
tx(f) {
|
|
85
|
+
if (this.depth) {
|
|
86
|
+
this.depth++;
|
|
87
|
+
try {
|
|
88
|
+
return f();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
this.depth--;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
95
|
+
this.depth = 1;
|
|
96
|
+
try {
|
|
97
|
+
const out = f();
|
|
98
|
+
this.db.exec('COMMIT');
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
this.db.exec('ROLLBACK');
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
this.depth = 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
session(id) {
|
|
110
|
+
const r = this.q.session.get(id);
|
|
111
|
+
return r ? { v: r.v, conv: r.conv, convRead: r.convRead } : null;
|
|
112
|
+
}
|
|
113
|
+
setConv(id, conv, convRead) {
|
|
114
|
+
this.q.setConv.run(id, conv, convRead);
|
|
115
|
+
}
|
|
116
|
+
bump(id) {
|
|
117
|
+
return this.q.bump.get(id).v;
|
|
118
|
+
}
|
|
119
|
+
// False when the item is there already: an item never changes once written.
|
|
120
|
+
putItem(id, conv, itemId, pos, body) {
|
|
121
|
+
return this.tx(() => {
|
|
122
|
+
if (this.q.hasItem.get(id, itemId))
|
|
123
|
+
return false;
|
|
124
|
+
this.q.putItem.run(id, itemId, conv, pos, this.bump(id), JSON.stringify(body));
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
lastPos(id, conv) {
|
|
129
|
+
return this.q.lastPos.get(id, conv).pos ?? -1;
|
|
130
|
+
}
|
|
131
|
+
itemsSince(id, conv, pos) {
|
|
132
|
+
return this.q.itemsSince.all(id, conv, pos).map((r) => ({ id: r.id, body: JSON.parse(r.body) }));
|
|
133
|
+
}
|
|
134
|
+
// Claude's words since the last message to it.
|
|
135
|
+
turnTexts(id, conv) {
|
|
136
|
+
const texts = [];
|
|
137
|
+
for (const r of this.q.newestFirst.iterate(id, conv)) {
|
|
138
|
+
const item = JSON.parse(r.body);
|
|
139
|
+
if (item.k === 'user')
|
|
140
|
+
break;
|
|
141
|
+
if (item.k === 'text')
|
|
142
|
+
texts.unshift(item);
|
|
143
|
+
}
|
|
144
|
+
return texts;
|
|
145
|
+
}
|
|
146
|
+
putMessage(id, m) {
|
|
147
|
+
this.tx(() => this.q.putMessage.run(id, m.id, this.bump(id), m.text, m.sentAt, m.state, m.ord, m.item));
|
|
148
|
+
}
|
|
149
|
+
// Oldest change first.
|
|
150
|
+
messages(id) {
|
|
151
|
+
return this.q.messages.all(id).map(toMessage);
|
|
152
|
+
}
|
|
153
|
+
message(id, qid) {
|
|
154
|
+
const r = this.q.message.get(id, qid);
|
|
155
|
+
return r ? messageRow(r) : null;
|
|
156
|
+
}
|
|
157
|
+
// Every session's messages in this state.
|
|
158
|
+
messagesIn(state) {
|
|
159
|
+
return this.q.inState.all(state).map((r) => ({ session: r.session, m: toMessage(r) }));
|
|
160
|
+
}
|
|
161
|
+
// False when the body is what is there already: the screen is read four times a second, and a read that
|
|
162
|
+
// finds it the same is no change.
|
|
163
|
+
putState(id, name, body) {
|
|
164
|
+
const json = JSON.stringify(body);
|
|
165
|
+
return this.tx(() => {
|
|
166
|
+
const now = this.q.stateBody.get(id, name);
|
|
167
|
+
if (now?.body === json)
|
|
168
|
+
return false;
|
|
169
|
+
this.q.putState.run(id, name, this.bump(id), json);
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// Every session that has this state.
|
|
174
|
+
sessionsWith(name) {
|
|
175
|
+
return this.q.withState.all(name).map((r) => r.session);
|
|
176
|
+
}
|
|
177
|
+
state(id, name) {
|
|
178
|
+
const r = this.q.stateBody.get(id, name);
|
|
179
|
+
return r ? JSON.parse(r.body) : undefined;
|
|
180
|
+
}
|
|
181
|
+
// The rows written after version v, oldest first, at most limit of them.
|
|
182
|
+
rowsAfter(id, v, limit) {
|
|
183
|
+
const rows = [
|
|
184
|
+
...this.q.itemsAfter.all(id, v, limit + 1).map(itemRow),
|
|
185
|
+
...this.q.messagesAfter.all(id, v, limit + 1).map(messageRow),
|
|
186
|
+
...this.q.statesAfter.all(id, v, limit + 1).map(stateRow),
|
|
187
|
+
].sort((a, b) => a.v - b.v);
|
|
188
|
+
return { rows: rows.slice(0, limit), more: rows.length > limit };
|
|
189
|
+
}
|
|
190
|
+
// What a copy starting over needs: every message and state, and the newest items of the conversation
|
|
191
|
+
// shown now. older: the pos before which there are more items, null when there are none.
|
|
192
|
+
firstRows(id, items) {
|
|
193
|
+
const conv = this.session(id)?.conv ?? null;
|
|
194
|
+
const newest = conv ? this.q.itemsBefore.all(id, conv, Number.MAX_SAFE_INTEGER, items + 1) : [];
|
|
195
|
+
const kept = newest.slice(0, items).reverse();
|
|
196
|
+
const rows = [...kept.map(itemRow), ...this.q.allMessages.all(id).map(messageRow), ...this.q.allStates.all(id).map(stateRow)];
|
|
197
|
+
return { rows, older: newest.length > items ? kept[0].pos : null };
|
|
198
|
+
}
|
|
199
|
+
// The items of conv before pos, oldest first, at most limit of them; older: the pos before which there
|
|
200
|
+
// are more, null when there are none.
|
|
201
|
+
itemsBefore(id, conv, pos, limit) {
|
|
202
|
+
const got = this.q.itemsBefore.all(id, conv, pos, limit + 1);
|
|
203
|
+
const kept = got.slice(0, limit).reverse();
|
|
204
|
+
return { rows: kept.map(itemRow), older: got.length > limit ? kept[0].pos : null };
|
|
205
|
+
}
|
|
206
|
+
// Each session's version.
|
|
207
|
+
heads() {
|
|
208
|
+
return Object.fromEntries(this.q.heads.all().map((r) => [r.id, r.v]));
|
|
209
|
+
}
|
|
210
|
+
drop(id) {
|
|
211
|
+
this.tx(() => {
|
|
212
|
+
for (const q of [this.q.dropItems, this.q.dropMessages, this.q.dropStates, this.q.dropSession])
|
|
213
|
+
q.run(id);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
close() {
|
|
217
|
+
this.db.close();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Node has SQLite built in from 22.13 (and 22.5 behind a flag); without it there is no journal.
|
|
221
|
+
export const sqliteAvailable = () => !!process.getBuiltinModule('node:sqlite');
|
|
222
|
+
function open(file) {
|
|
223
|
+
const sqlite = process.getBuiltinModule('node:sqlite');
|
|
224
|
+
const db = new sqlite.DatabaseSync(file);
|
|
225
|
+
try {
|
|
226
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 2000;');
|
|
227
|
+
db.exec(SCHEMA);
|
|
228
|
+
// What was said in each session: its owner's only, as everything else in the home is.
|
|
229
|
+
for (const f of [file, `${file}-wal`, `${file}-shm`])
|
|
230
|
+
if (existsSync(f))
|
|
231
|
+
chmodSync(f, 0o600);
|
|
232
|
+
const row = db.prepare('SELECT epoch FROM journal LIMIT 1').get();
|
|
233
|
+
if (row)
|
|
234
|
+
return { db, epoch: row.epoch };
|
|
235
|
+
const epoch = randomUUID();
|
|
236
|
+
db.prepare('INSERT INTO journal (epoch) VALUES (?)').run(epoch);
|
|
237
|
+
return { db, epoch };
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
db.close();
|
|
241
|
+
throw e;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function taskList(items) {
|
|
2
|
+
let tasks = new Map();
|
|
3
|
+
for (const i of items) {
|
|
4
|
+
if (i.k === 'todos')
|
|
5
|
+
tasks = new Map(i.items.map((t, n) => [`todo-${n}`, t]));
|
|
6
|
+
if (i.k !== 'task')
|
|
7
|
+
continue;
|
|
8
|
+
if (i.status === 'deleted') {
|
|
9
|
+
tasks.delete(i.id);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const was = tasks.get(i.id);
|
|
13
|
+
const subject = i.subject ?? was?.subject;
|
|
14
|
+
if (subject)
|
|
15
|
+
tasks.set(i.id, { subject, status: i.status ?? was?.status ?? 'pending' });
|
|
16
|
+
}
|
|
17
|
+
return [...tasks.values()];
|
|
18
|
+
}
|
|
19
|
+
export function backgroundRunning(items) {
|
|
20
|
+
const tools = new Map();
|
|
21
|
+
const running = new Map();
|
|
22
|
+
for (const i of items) {
|
|
23
|
+
if (i.k === 'tool')
|
|
24
|
+
tools.set(i.id, i.summary);
|
|
25
|
+
if (i.k !== 'bg')
|
|
26
|
+
continue;
|
|
27
|
+
if (i.status === 'running')
|
|
28
|
+
running.set(i.id, { id: i.id, label: (i.tool && tools.get(i.tool)) || i.id });
|
|
29
|
+
else
|
|
30
|
+
running.delete(i.id);
|
|
31
|
+
}
|
|
32
|
+
return [...running.values()];
|
|
33
|
+
}
|
|
34
|
+
// How much of what Claude is writing is compared with what the transcript already has.
|
|
35
|
+
const LIVE_PROBE = 40;
|
|
36
|
+
// The screen shows Markdown drawn, the transcript has it written: both are compared without its marks.
|
|
37
|
+
const flat = (s) => s.replace(/[\s*_`#>~|-]+/g, '');
|
|
38
|
+
// The reply Claude is writing, unless the transcript has it already among the items since the last
|
|
39
|
+
// message to Claude (then the reply shows, and the preview would be a second copy).
|
|
40
|
+
export function liveShown(live, items) {
|
|
41
|
+
if (!live)
|
|
42
|
+
return null;
|
|
43
|
+
const probe = flat(live).slice(0, LIVE_PROBE);
|
|
44
|
+
for (let i = items.length - 1; i >= 0 && items[i].k !== 'user'; i--) {
|
|
45
|
+
const t = items[i];
|
|
46
|
+
if (t.k === 'text' && flat(t.text).includes(probe))
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return live;
|
|
50
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { JournalDb } from './db.js';
|
|
2
|
+
import { liveShown } from './derive.js';
|
|
3
|
+
import { ingest, landFound, onTheirWay } from './ingest.js';
|
|
4
|
+
const NOT_LANDED = new Set(['delivering', 'delivered', 'enqueued', 'unconfirmed']);
|
|
5
|
+
// As many rows as one answer carries; a copy further behind asks again.
|
|
6
|
+
const PAGE_ROWS = 500;
|
|
7
|
+
// The items a copy starting over is given, and a page of older ones.
|
|
8
|
+
const PAGE_ITEMS = 100;
|
|
9
|
+
// What the Machine and the routes write to and read from the journal (db.ts), and who is told of new rows.
|
|
10
|
+
export class Journal {
|
|
11
|
+
epoch;
|
|
12
|
+
db;
|
|
13
|
+
root;
|
|
14
|
+
log;
|
|
15
|
+
listeners = new Map();
|
|
16
|
+
// Per session listened to, the version its listeners were last told up to.
|
|
17
|
+
told = new Map();
|
|
18
|
+
headListeners = new Set();
|
|
19
|
+
running = new Map();
|
|
20
|
+
queued = new Map();
|
|
21
|
+
// The reply being written as the screen last read it, and the last one it showed, kept until the
|
|
22
|
+
// transcript grows: the screen stops showing it (a tool runs, the turn ends) before the transcript has it.
|
|
23
|
+
screenLive = new Map();
|
|
24
|
+
heldLive = new Map();
|
|
25
|
+
titled;
|
|
26
|
+
constructor(o) {
|
|
27
|
+
this.db = new JournalDb(o.file, o.log);
|
|
28
|
+
this.epoch = this.db.epoch;
|
|
29
|
+
this.root = o.root;
|
|
30
|
+
this.log = o.log;
|
|
31
|
+
this.titled = o.titled;
|
|
32
|
+
// A paste the server stopped in the middle of may have gone in: it is not pasted again.
|
|
33
|
+
for (const { session, m } of this.db.messagesIn('delivering'))
|
|
34
|
+
this.db.putMessage(session, { ...m, state: 'delivered' });
|
|
35
|
+
const notLanded = [...this.db.messagesIn('delivered'), ...this.db.messagesIn('enqueued'), ...this.db.messagesIn('unconfirmed')];
|
|
36
|
+
for (const session of new Set(notLanded.map((r) => r.session)))
|
|
37
|
+
landFound(this.db, session, this.log);
|
|
38
|
+
// An ask lives in the server that holds it (PermissionAsks, Codex's agent) and goes with it: none is open
|
|
39
|
+
// when a server starts, whatever the last one wrote.
|
|
40
|
+
for (const session of this.db.sessionsWith('asks'))
|
|
41
|
+
this.db.putState(session, 'asks', []);
|
|
42
|
+
}
|
|
43
|
+
// The messages sent to a session through WorkOffDesk (the outbox's storage), oldest change first.
|
|
44
|
+
messages(id) {
|
|
45
|
+
return this.db.messages(id);
|
|
46
|
+
}
|
|
47
|
+
putMessage(id, m) {
|
|
48
|
+
this.db.putMessage(id, m);
|
|
49
|
+
this.tell(id);
|
|
50
|
+
}
|
|
51
|
+
// A message as a row, to answer what was done to it with.
|
|
52
|
+
message(id, qid) {
|
|
53
|
+
return this.db.message(id, qid);
|
|
54
|
+
}
|
|
55
|
+
// workingSince: when Claude set out on what it is doing, by this Mac's clock.
|
|
56
|
+
status(id, status) {
|
|
57
|
+
const wrote = this.db.tx(() => {
|
|
58
|
+
const changed = this.db.putState(id, 'status', status);
|
|
59
|
+
const since = status === 'working' ? changed && this.db.putState(id, 'workingSince', Date.now()) : this.db.putState(id, 'workingSince', null);
|
|
60
|
+
return changed || since;
|
|
61
|
+
});
|
|
62
|
+
if (wrote)
|
|
63
|
+
this.tell(id);
|
|
64
|
+
if (status === 'idle' || status === 'ended')
|
|
65
|
+
this.notFoundAfterIngest(id);
|
|
66
|
+
}
|
|
67
|
+
screen(id, s) {
|
|
68
|
+
const { live, ...screen } = s;
|
|
69
|
+
this.screenLive.set(id, live);
|
|
70
|
+
if (live)
|
|
71
|
+
this.heldLive.set(id, live);
|
|
72
|
+
const shown = live ?? this.heldLive.get(id) ?? null;
|
|
73
|
+
const conv = this.db.session(id)?.conv;
|
|
74
|
+
const wrote = this.db.tx(() => {
|
|
75
|
+
const a = this.db.putState(id, 'screen', screen);
|
|
76
|
+
const b = this.db.putState(id, 'live', { text: shown && liveShown(shown, conv ? this.db.turnTexts(id, conv) : []) });
|
|
77
|
+
return a || b;
|
|
78
|
+
});
|
|
79
|
+
if (wrote)
|
|
80
|
+
this.tell(id);
|
|
81
|
+
}
|
|
82
|
+
// What the screen shows, for an agent that says itself what it is writing (Codex).
|
|
83
|
+
screenAsIs(id, screen) {
|
|
84
|
+
if (this.db.putState(id, 'screen', screen))
|
|
85
|
+
this.tell(id);
|
|
86
|
+
}
|
|
87
|
+
asks(id, asks) {
|
|
88
|
+
if (this.db.putState(id, 'asks', asks))
|
|
89
|
+
this.tell(id);
|
|
90
|
+
}
|
|
91
|
+
// How many terminals on the Mac have the session's conversation open in a Claude of their own, each one
|
|
92
|
+
// writing it beside the session's.
|
|
93
|
+
onMac(id, terminals) {
|
|
94
|
+
if (this.db.putState(id, 'onMac', terminals))
|
|
95
|
+
this.tell(id);
|
|
96
|
+
}
|
|
97
|
+
// Items an agent hands over itself (Codex), in order; one already in is left as it is. A user message that
|
|
98
|
+
// carries the id a message was sent under is that message: it landed.
|
|
99
|
+
putAgentItems(id, conv, entries) {
|
|
100
|
+
let wrote = false;
|
|
101
|
+
this.db.tx(() => {
|
|
102
|
+
if (this.db.session(id)?.conv !== conv) {
|
|
103
|
+
this.db.setConv(id, conv, 0);
|
|
104
|
+
this.db.putState(id, 'conv', { conv });
|
|
105
|
+
wrote = true;
|
|
106
|
+
}
|
|
107
|
+
let pos = this.db.lastPos(id, conv);
|
|
108
|
+
for (const e of entries) {
|
|
109
|
+
if (!this.db.putItem(id, conv, e.id, pos + 1, e.item))
|
|
110
|
+
continue;
|
|
111
|
+
pos++;
|
|
112
|
+
wrote = true;
|
|
113
|
+
const m = e.clientId && this.db.messages(id).find((r) => r.id === e.clientId);
|
|
114
|
+
if (m && NOT_LANDED.has(m.state))
|
|
115
|
+
this.db.putMessage(id, { ...m, state: 'landed', item: e.id });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
if (wrote)
|
|
119
|
+
this.tell(id);
|
|
120
|
+
}
|
|
121
|
+
// The reply an agent is writing, as it says it; null once it is written.
|
|
122
|
+
live(id, text) {
|
|
123
|
+
if (this.db.putState(id, 'live', { text }))
|
|
124
|
+
this.tell(id);
|
|
125
|
+
}
|
|
126
|
+
// Takes in what the conversation has added. One run per session at a time; asked meanwhile, one more after.
|
|
127
|
+
ingest(id, conv) {
|
|
128
|
+
const waiting = this.queued.get(id);
|
|
129
|
+
if (waiting) {
|
|
130
|
+
waiting.conv = conv;
|
|
131
|
+
return waiting.p;
|
|
132
|
+
}
|
|
133
|
+
const before = this.running.get(id);
|
|
134
|
+
if (!before)
|
|
135
|
+
return this.run(id, conv);
|
|
136
|
+
const next = { conv, p: Promise.resolve() };
|
|
137
|
+
next.p = before.then(() => {
|
|
138
|
+
this.queued.delete(id);
|
|
139
|
+
return this.run(id, next.conv);
|
|
140
|
+
});
|
|
141
|
+
this.queued.set(id, next);
|
|
142
|
+
return next.p;
|
|
143
|
+
}
|
|
144
|
+
sync(id, epoch, v) {
|
|
145
|
+
const now = this.db.session(id)?.v ?? 0;
|
|
146
|
+
if (epoch !== this.epoch || v === 0 || v > now) {
|
|
147
|
+
const first = this.db.firstRows(id, PAGE_ITEMS);
|
|
148
|
+
return { epoch: this.epoch, v: now, rows: first.rows, more: false, reset: true, older: first.older };
|
|
149
|
+
}
|
|
150
|
+
const { rows, more } = this.db.rowsAfter(id, v, PAGE_ROWS);
|
|
151
|
+
return { epoch: this.epoch, v: more ? rows[rows.length - 1].v : now, rows, more };
|
|
152
|
+
}
|
|
153
|
+
older(id, conv, before) {
|
|
154
|
+
return this.db.itemsBefore(id, conv, before, PAGE_ITEMS);
|
|
155
|
+
}
|
|
156
|
+
// Calls cb with each change's rows: from is the version the last call ended at, to the one this ends at.
|
|
157
|
+
onRows(id, cb) {
|
|
158
|
+
let set = this.listeners.get(id);
|
|
159
|
+
if (!set) {
|
|
160
|
+
set = new Set();
|
|
161
|
+
this.listeners.set(id, set);
|
|
162
|
+
this.told.set(id, this.db.session(id)?.v ?? 0);
|
|
163
|
+
}
|
|
164
|
+
set.add(cb);
|
|
165
|
+
return () => {
|
|
166
|
+
set.delete(cb);
|
|
167
|
+
if (set.size || this.listeners.get(id) !== set)
|
|
168
|
+
return;
|
|
169
|
+
this.listeners.delete(id);
|
|
170
|
+
this.told.delete(id);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// Each session's version, for a device to tell whether the copy it kept is behind before asking.
|
|
174
|
+
heads() {
|
|
175
|
+
return this.db.heads();
|
|
176
|
+
}
|
|
177
|
+
// Calls cb after anything is written to any session.
|
|
178
|
+
onHead(cb) {
|
|
179
|
+
this.headListeners.add(cb);
|
|
180
|
+
return () => this.headListeners.delete(cb);
|
|
181
|
+
}
|
|
182
|
+
drop(id) {
|
|
183
|
+
this.db.drop(id);
|
|
184
|
+
this.screenLive.delete(id);
|
|
185
|
+
this.heldLive.delete(id);
|
|
186
|
+
for (const l of this.headListeners)
|
|
187
|
+
l();
|
|
188
|
+
}
|
|
189
|
+
close() {
|
|
190
|
+
this.db.close();
|
|
191
|
+
}
|
|
192
|
+
run(id, conv) {
|
|
193
|
+
const p = this.ingestNow(id, conv)
|
|
194
|
+
.catch((e) => this.log('journal ingest failed', { session: id, error: e instanceof Error ? e.message : String(e) }))
|
|
195
|
+
.finally(() => this.running.get(id) === p && this.running.delete(id));
|
|
196
|
+
this.running.set(id, p);
|
|
197
|
+
return p;
|
|
198
|
+
}
|
|
199
|
+
async ingestNow(id, conv) {
|
|
200
|
+
if (!this.root)
|
|
201
|
+
return;
|
|
202
|
+
const wrote = await ingest(this.db, id, conv, {
|
|
203
|
+
root: this.root,
|
|
204
|
+
live: (grew) => {
|
|
205
|
+
const live = this.screenLive.get(id) ?? null;
|
|
206
|
+
if (grew && !live)
|
|
207
|
+
this.heldLive.delete(id);
|
|
208
|
+
return live ?? this.heldLive.get(id) ?? null;
|
|
209
|
+
},
|
|
210
|
+
log: this.log,
|
|
211
|
+
titled: (found, fresh) => this.titled?.(id, found, fresh),
|
|
212
|
+
});
|
|
213
|
+
if (wrote)
|
|
214
|
+
this.tell(id);
|
|
215
|
+
}
|
|
216
|
+
// A message delivered before the turn was over that what Claude took in does not have is unconfirmed:
|
|
217
|
+
// shown where it was sent, and said, so a change in how Claude Code records a prompt is seen rather
|
|
218
|
+
// than guessed at. Only those on their way when the turn ended: one delivered after is the next turn's.
|
|
219
|
+
notFoundAfterIngest(id) {
|
|
220
|
+
const waiting = onTheirWay(this.db, id).map((m) => m.id);
|
|
221
|
+
if (!waiting.length)
|
|
222
|
+
return;
|
|
223
|
+
void this.ingest(id, this.db.session(id)?.conv ?? null).then(() => {
|
|
224
|
+
let wrote = false;
|
|
225
|
+
for (const m of this.db.messages(id)) {
|
|
226
|
+
if ((m.state !== 'delivered' && m.state !== 'enqueued') || !waiting.includes(m.id))
|
|
227
|
+
continue;
|
|
228
|
+
this.db.putMessage(id, { ...m, state: 'unconfirmed' });
|
|
229
|
+
this.log('message not found in the transcript', { session: id, qid: m.id, chars: m.text.length });
|
|
230
|
+
wrote = true;
|
|
231
|
+
}
|
|
232
|
+
if (wrote)
|
|
233
|
+
this.tell(id);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
tell(id) {
|
|
237
|
+
for (const l of this.headListeners)
|
|
238
|
+
l();
|
|
239
|
+
const set = this.listeners.get(id);
|
|
240
|
+
if (!set?.size)
|
|
241
|
+
return;
|
|
242
|
+
let from = this.told.get(id) ?? 0;
|
|
243
|
+
for (;;) {
|
|
244
|
+
const { rows, more } = this.db.rowsAfter(id, from, PAGE_ROWS);
|
|
245
|
+
if (!rows.length)
|
|
246
|
+
return;
|
|
247
|
+
const to = more ? rows[rows.length - 1].v : (this.db.session(id)?.v ?? from);
|
|
248
|
+
this.told.set(id, to);
|
|
249
|
+
for (const cb of set)
|
|
250
|
+
cb(from, to, rows);
|
|
251
|
+
from = to;
|
|
252
|
+
if (!more)
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|