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,71 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { InvalidRequest } from '../../system/errors.js';
|
|
3
|
+
// Wide and tall enough that a dialog's lines and a list of options are read whole.
|
|
4
|
+
export const CHAT_SIZE = { cols: 100, rows: 50 };
|
|
5
|
+
export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
export const ID = /^[a-z0-9]{1,32}$/;
|
|
7
|
+
// As much of a name as a row of the session list has room for.
|
|
8
|
+
const TITLE_MAX = 40;
|
|
9
|
+
// A name on one line, in characters rather than UTF-16 units; empty for none.
|
|
10
|
+
export const nameLine = (name) => [...(name ?? '').replace(/[\s\p{Cc}]+/gu, ' ').trim()].slice(0, TITLE_MAX).join('').trimEnd();
|
|
11
|
+
// A session's name; none is the directory's name.
|
|
12
|
+
export function sessionTitle(name, cwd) {
|
|
13
|
+
return nameLine(name) || basename(cwd) || cwd;
|
|
14
|
+
}
|
|
15
|
+
// The names of the latest conversations named are kept; a name given again counts as the latest.
|
|
16
|
+
const CLAUDE_NAMES_KEPT = 200;
|
|
17
|
+
export function nameConversation(st, claudeSessionId, name) {
|
|
18
|
+
delete st.claudeNames[claudeSessionId];
|
|
19
|
+
if (name)
|
|
20
|
+
st.claudeNames[claudeSessionId] = name;
|
|
21
|
+
const names = Object.keys(st.claudeNames);
|
|
22
|
+
for (const old of names.slice(0, Math.max(0, names.length - CLAUDE_NAMES_KEPT)))
|
|
23
|
+
delete st.claudeNames[old];
|
|
24
|
+
}
|
|
25
|
+
// What more than one of the machine's parts reads or changes, per session, and who is told of a change.
|
|
26
|
+
export class MachineState {
|
|
27
|
+
statuses = new Map();
|
|
28
|
+
usages = new Map();
|
|
29
|
+
// Sessions waiting on a choice only the screen shows, and the status they had before it.
|
|
30
|
+
screenWaits = new Map();
|
|
31
|
+
// When each session's conversation was last looked for on the Mac.
|
|
32
|
+
macLooks = new Map();
|
|
33
|
+
// Per Claude session, the watch on its transcript that takes what is added into the journal.
|
|
34
|
+
transcriptWatches = new Map();
|
|
35
|
+
// Per Claude conversation, the session being started to go on with it: asked again meanwhile, the same one.
|
|
36
|
+
resuming = new Map();
|
|
37
|
+
listeners = new Set();
|
|
38
|
+
notifier = null;
|
|
39
|
+
// What the machine is to do later; null once the journal is closed, when nothing is.
|
|
40
|
+
timers = new Set();
|
|
41
|
+
emit() {
|
|
42
|
+
for (const cb of this.listeners)
|
|
43
|
+
cb();
|
|
44
|
+
}
|
|
45
|
+
// Runs f in ms, unless the journal is closed first; the function returned stops it sooner.
|
|
46
|
+
later(ms, f) {
|
|
47
|
+
const timers = this.timers;
|
|
48
|
+
if (!timers)
|
|
49
|
+
return () => { };
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
timers.delete(timer);
|
|
52
|
+
f();
|
|
53
|
+
}, ms).unref();
|
|
54
|
+
timers.add(timer);
|
|
55
|
+
return () => {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
timers.delete(timer);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// With the journal closed: what was to be done later is not, and nothing more is.
|
|
61
|
+
stopTimers() {
|
|
62
|
+
for (const timer of this.timers ?? [])
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
this.timers = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function codexOf(d) {
|
|
68
|
+
if (!d.codex)
|
|
69
|
+
throw new InvalidRequest('Codex is not set up on this Mac');
|
|
70
|
+
return d.codex;
|
|
71
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { findTranscript } from '../../agents/claude/transcript/transcriptFile.js';
|
|
2
|
+
import { notificationFor } from '../../agents/claude/hooks/status.js';
|
|
3
|
+
import { turnInTranscript } from '../../agents/claude/transcript/turn.js';
|
|
4
|
+
import { watchTranscript } from '../../agents/claude/transcript/watch.js';
|
|
5
|
+
import { shownTitle } from '../../system/store.js';
|
|
6
|
+
import { agentsRunning } from '../panes.js';
|
|
7
|
+
import { tmuxName } from '../sessionName.js';
|
|
8
|
+
import { UUID, nameConversation } from './state.js';
|
|
9
|
+
// A turn the transcript says is over, while no hook said so, is taken as over this long after: the hook
|
|
10
|
+
// for it may be on its way, and Claude may go on at once with what it was sent meanwhile.
|
|
11
|
+
const TURN_OVER_AFTER_MS = 10_000;
|
|
12
|
+
// How often a session working with no word from the hooks has its transcript read.
|
|
13
|
+
const TURN_LOOK_MS = 5_000;
|
|
14
|
+
// Following each agent: its status, its turns, compacting, quitting, its transcript, its usage.
|
|
15
|
+
export class Status {
|
|
16
|
+
s;
|
|
17
|
+
d;
|
|
18
|
+
p;
|
|
19
|
+
// When each working session's transcript was last read for the end of its turn.
|
|
20
|
+
turnLooks = new Map();
|
|
21
|
+
// When each session last went to work: an end the transcript recorded before then is an earlier turn's.
|
|
22
|
+
workSince = new Map();
|
|
23
|
+
// While compacting: the status it came from, and whether its spinner has been seen on screen.
|
|
24
|
+
compactions = new Map();
|
|
25
|
+
constructor(s, d, p) {
|
|
26
|
+
this.s = s;
|
|
27
|
+
this.d = d;
|
|
28
|
+
this.p = p;
|
|
29
|
+
}
|
|
30
|
+
// A status an agent reported, told as a hook's would be.
|
|
31
|
+
moved(id, status, said) {
|
|
32
|
+
const meta = this.d.store.get().sessions[id];
|
|
33
|
+
const prev = this.getStatus(id);
|
|
34
|
+
if (!meta || prev === status)
|
|
35
|
+
return;
|
|
36
|
+
this.setStatus(id, status);
|
|
37
|
+
// What it asked on screen before its thread started is gone once the thread says how it is.
|
|
38
|
+
void this.p.input.onScreen(id).catch(() => { });
|
|
39
|
+
const n = notificationFor(prev, status, shownTitle(meta));
|
|
40
|
+
if (n && this.s.notifier)
|
|
41
|
+
void this.s.notifier.notify(id, n.title, n.event, said).catch(() => { });
|
|
42
|
+
}
|
|
43
|
+
// A new process knows no statuses: what the screen shows stands in until a hook says otherwise, and a
|
|
44
|
+
// hook that comes while the screen is being read wins.
|
|
45
|
+
filling = new Map();
|
|
46
|
+
fillStatus(id) {
|
|
47
|
+
if (this.s.statuses.has(id))
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
const reading = this.filling.get(id) ?? this.readStatus(id).finally(() => this.filling.delete(id));
|
|
50
|
+
this.filling.set(id, reading);
|
|
51
|
+
return reading;
|
|
52
|
+
}
|
|
53
|
+
async readStatus(id) {
|
|
54
|
+
try {
|
|
55
|
+
const { choices, panel } = await this.p.input.onScreen(id);
|
|
56
|
+
// Between calls Claude shows its prompt as it does when done: the transcript says which.
|
|
57
|
+
const going = !choices && !panel && (await this.turn(id))?.over === false;
|
|
58
|
+
if (!this.s.statuses.has(id))
|
|
59
|
+
this.setStatus(id, choices || panel ? 'waiting' : going ? 'working' : 'idle');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// gone meanwhile; the next listing drops it
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// A Claude session whose Claude no longer runs, however it ended, has ended; one marked ended whose Claude runs
|
|
66
|
+
// is read again from its screen.
|
|
67
|
+
async followClaudes(panes) {
|
|
68
|
+
const running = await agentsRunning(panes.map((p) => p.panePid));
|
|
69
|
+
if (!running)
|
|
70
|
+
return;
|
|
71
|
+
for (const { id, panePid } of panes) {
|
|
72
|
+
// One being gone on with is looked at once its Claude was started.
|
|
73
|
+
const sid = this.d.store.get().sessions[id]?.claudeSessionId;
|
|
74
|
+
if (sid && this.s.resuming.has(sid))
|
|
75
|
+
continue;
|
|
76
|
+
if (!running.get(panePid))
|
|
77
|
+
this.claudeQuit(id);
|
|
78
|
+
else if (this.s.statuses.get(id) === 'ended')
|
|
79
|
+
this.s.statuses.delete(id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
claudeQuit(id) {
|
|
83
|
+
if (this.s.statuses.get(id) === 'ended')
|
|
84
|
+
return;
|
|
85
|
+
this.d.asks.endAll(id);
|
|
86
|
+
this.setStatus(id, 'ended');
|
|
87
|
+
}
|
|
88
|
+
// Whether what is typed into a session goes to its agent rather than the shell an ended Claude left; when it
|
|
89
|
+
// cannot be looked at, it is taken as running.
|
|
90
|
+
async agentRuns(id) {
|
|
91
|
+
if (this.d.store.get().sessions[id]?.kind !== 'claude')
|
|
92
|
+
return true;
|
|
93
|
+
const pane = await this.d.tmux.panePid(tmuxName(id)).catch(() => undefined);
|
|
94
|
+
const runs = pane === undefined ? undefined : (await agentsRunning([pane]))?.get(pane);
|
|
95
|
+
if (runs === false)
|
|
96
|
+
this.claudeQuit(id);
|
|
97
|
+
return runs !== false;
|
|
98
|
+
}
|
|
99
|
+
// Told Claude is ending (SessionEnd comes on /clear too), its process is looked at until it is gone, for a few
|
|
100
|
+
// seconds; the listing would see it within one. One quit to be started again is not.
|
|
101
|
+
claudeMayHaveQuit(id) {
|
|
102
|
+
const sid = this.d.store.get().sessions[id]?.claudeSessionId;
|
|
103
|
+
if (sid && this.s.resuming.has(sid))
|
|
104
|
+
return;
|
|
105
|
+
let looks = 15;
|
|
106
|
+
const look = async () => {
|
|
107
|
+
if ((await this.agentRuns(id)) && --looks > 0)
|
|
108
|
+
this.s.later(200, () => void look().catch(() => { }));
|
|
109
|
+
};
|
|
110
|
+
void look().catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
setStatus(id, status) {
|
|
113
|
+
if (status !== 'waiting')
|
|
114
|
+
this.s.screenWaits.delete(id);
|
|
115
|
+
if (status !== 'compacting')
|
|
116
|
+
this.compactions.delete(id);
|
|
117
|
+
if (this.s.statuses.get(id) === status)
|
|
118
|
+
return;
|
|
119
|
+
if (status === 'working')
|
|
120
|
+
this.workSince.set(id, Date.now());
|
|
121
|
+
this.s.statuses.set(id, status);
|
|
122
|
+
this.d.journal.status(id, status);
|
|
123
|
+
this.s.emit();
|
|
124
|
+
if (this.d.outbox.list(id).length)
|
|
125
|
+
void this.d.outbox.flush(id);
|
|
126
|
+
if (status === 'idle')
|
|
127
|
+
void this.p.moves.turnOver(id);
|
|
128
|
+
}
|
|
129
|
+
// What each listing checks of a Claude session: its status when not known, whether a compaction is still
|
|
130
|
+
// on screen, and whether what waits for it can go.
|
|
131
|
+
look(id) {
|
|
132
|
+
void this.fillStatus(id);
|
|
133
|
+
this.watchTranscript(id);
|
|
134
|
+
// A choice or dialog no hook reports (trust this folder? before it starts, /model at the prompt) is a
|
|
135
|
+
// wait read off the screen, and it is over when the screen no longer shows it.
|
|
136
|
+
const st = this.s.statuses.get(id);
|
|
137
|
+
if (st === 'starting' || st === 'idle' || this.s.screenWaits.has(id)) {
|
|
138
|
+
void this.p.input.onScreen(id)
|
|
139
|
+
.then(({ choices, panel }) => (choices || panel ? this.p.input.waitOnScreen(id) : this.p.input.waitOnScreenOver(id)))
|
|
140
|
+
.catch(() => { });
|
|
141
|
+
}
|
|
142
|
+
if (this.s.statuses.get(id) === 'compacting')
|
|
143
|
+
void this.p.input.onScreen(id).catch(() => { });
|
|
144
|
+
if (this.s.statuses.get(id) === 'working')
|
|
145
|
+
void this.endTurnHooksMissed(id);
|
|
146
|
+
// Until its status is known, nothing goes to it: it may be at work.
|
|
147
|
+
if (this.s.statuses.has(id) && this.d.outbox.list(id).length)
|
|
148
|
+
void this.d.outbox.flush(id);
|
|
149
|
+
}
|
|
150
|
+
// A Codex session's thread is followed (again, after a restart), and what waits for it goes once it can.
|
|
151
|
+
lookCodex(id, meta) {
|
|
152
|
+
this.d.codex?.follow(id, meta.codexThreadId);
|
|
153
|
+
// One with no thread yet, as this server finds it after a restart, has not started.
|
|
154
|
+
if (!meta.codexThreadId && !this.s.statuses.has(id))
|
|
155
|
+
this.setStatus(id, 'starting');
|
|
156
|
+
if (this.p.input.beforeThread(id))
|
|
157
|
+
void this.p.input.onScreen(id)
|
|
158
|
+
.then(({ choices }) => (choices && this.p.input.beforeThread(id) ? this.p.input.waitOnScreen(id) : this.p.input.waitOnScreenOver(id)))
|
|
159
|
+
.catch(() => { });
|
|
160
|
+
if (this.s.statuses.has(id) && this.d.outbox.list(id).length)
|
|
161
|
+
void this.d.outbox.flush(id);
|
|
162
|
+
}
|
|
163
|
+
// What Claude Code's transcript says of the turn a session is on; null when there is none to read.
|
|
164
|
+
async turn(id) {
|
|
165
|
+
const sid = this.d.store.get().sessions[id]?.claudeSessionId;
|
|
166
|
+
if (!this.d.claudeProjectsRoot || !sid)
|
|
167
|
+
return null;
|
|
168
|
+
const path = await findTranscript(this.d.claudeProjectsRoot, sid).catch(() => null);
|
|
169
|
+
return path ? turnInTranscript(path) : null;
|
|
170
|
+
}
|
|
171
|
+
// A turn can end with no hook to say so (Esc typed at the Mac, a hook Claude did not send): once the
|
|
172
|
+
// transcript has said for a while that it is over, so is the work, and what waits can go.
|
|
173
|
+
async endTurnHooksMissed(id) {
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
if (now - (this.turnLooks.get(id) ?? 0) < TURN_LOOK_MS)
|
|
176
|
+
return;
|
|
177
|
+
this.turnLooks.set(id, now);
|
|
178
|
+
const turn = await this.turn(id);
|
|
179
|
+
const ended = turn?.over && turn.at >= (this.workSince.get(id) ?? 0);
|
|
180
|
+
if (ended && Date.now() - turn.at >= TURN_OVER_AFTER_MS && this.s.statuses.get(id) === 'working')
|
|
181
|
+
this.setStatus(id, 'idle');
|
|
182
|
+
}
|
|
183
|
+
watchTranscript(id) {
|
|
184
|
+
if (!this.d.claudeProjectsRoot || this.s.transcriptWatches.has(id))
|
|
185
|
+
return;
|
|
186
|
+
const stop = watchTranscript(this.d.claudeProjectsRoot, () => this.d.store.get().sessions[id]?.claudeSessionId, () => void this.p.sessions.ingest(id));
|
|
187
|
+
this.s.transcriptWatches.set(id, stop);
|
|
188
|
+
}
|
|
189
|
+
// A session gone from tmux is gone for good, and so are its rows; a device keeps its own copy.
|
|
190
|
+
forget(id) {
|
|
191
|
+
this.s.transcriptWatches.get(id)?.();
|
|
192
|
+
this.s.transcriptWatches.delete(id);
|
|
193
|
+
this.d.journal.drop(id);
|
|
194
|
+
this.s.statuses.delete(id);
|
|
195
|
+
this.workSince.delete(id);
|
|
196
|
+
this.turnLooks.delete(id);
|
|
197
|
+
this.compactions.delete(id);
|
|
198
|
+
this.s.screenWaits.delete(id);
|
|
199
|
+
this.s.usages.delete(id);
|
|
200
|
+
this.d.asks.endAll(id);
|
|
201
|
+
this.d.codex?.forget(id);
|
|
202
|
+
this.s.macLooks.delete(id);
|
|
203
|
+
}
|
|
204
|
+
compactStarted(id) {
|
|
205
|
+
if (this.compactions.has(id))
|
|
206
|
+
return;
|
|
207
|
+
const before = this.s.statuses.get(id) ?? 'idle';
|
|
208
|
+
this.setStatus(id, 'compacting');
|
|
209
|
+
this.compactions.set(id, { before, seen: false });
|
|
210
|
+
}
|
|
211
|
+
// One asked for with /compact ends at the prompt; one Claude made on its own goes back to what it was doing.
|
|
212
|
+
compactEnded(id, manual) {
|
|
213
|
+
const c = this.compactions.get(id);
|
|
214
|
+
if (c)
|
|
215
|
+
this.setStatus(id, manual ? 'idle' : c.before);
|
|
216
|
+
}
|
|
217
|
+
// The screen ends only a compaction whose spinner it has shown: a spinner it cannot make out says nothing.
|
|
218
|
+
compactingOnScreen(id, shown) {
|
|
219
|
+
if (shown) {
|
|
220
|
+
this.compactStarted(id);
|
|
221
|
+
this.compactions.get(id).seen = true;
|
|
222
|
+
}
|
|
223
|
+
else if (this.compactions.get(id)?.seen) {
|
|
224
|
+
this.compactEnded(id, false);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
setUsage(id, usage) {
|
|
228
|
+
if (!usage || !this.d.store.get().sessions[id])
|
|
229
|
+
return;
|
|
230
|
+
if (JSON.stringify(this.s.usages.get(id)) === JSON.stringify(usage))
|
|
231
|
+
return;
|
|
232
|
+
this.s.usages.set(id, usage);
|
|
233
|
+
const meta = this.d.store.get().sessions[id];
|
|
234
|
+
if (meta.kind === 'claude' && meta.claudeDir !== undefined && (usage.fiveHour || usage.sevenDay))
|
|
235
|
+
this.p.accounts.keepLimits(meta.claudeDir, usage);
|
|
236
|
+
this.s.emit();
|
|
237
|
+
}
|
|
238
|
+
getStatus(id) {
|
|
239
|
+
return this.s.statuses.get(id) ?? null;
|
|
240
|
+
}
|
|
241
|
+
// The Claude session running in a workoffdesk session, as its hooks report it.
|
|
242
|
+
setClaudeSessionId(id, claudeSessionId) {
|
|
243
|
+
if (!UUID.test(claudeSessionId))
|
|
244
|
+
return;
|
|
245
|
+
const meta = this.d.store.get().sessions[id];
|
|
246
|
+
if (!meta || meta.claudeSessionId === claudeSessionId)
|
|
247
|
+
return;
|
|
248
|
+
this.d.store.update((st) => {
|
|
249
|
+
const name = meta.claudeSessionId && st.claudeNames[meta.claudeSessionId];
|
|
250
|
+
if (name)
|
|
251
|
+
nameConversation(st, claudeSessionId, name);
|
|
252
|
+
st.sessions[id].claudeSessionId = claudeSessionId;
|
|
253
|
+
});
|
|
254
|
+
void this.p.sessions.ingest(id);
|
|
255
|
+
this.s.emit();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { PermissionAsks } from '../agents/claude/hooks/asks.js';
|
|
3
|
+
import { ClaudeScreen } from '../agents/claude/screen/screen.js';
|
|
4
|
+
import { CodexAgent } from '../agents/codex/agent.js';
|
|
5
|
+
import { CodexThreads } from '../agents/codex/threads.js';
|
|
6
|
+
import { sqliteAvailable } from '../journal/db.js';
|
|
7
|
+
import { Journal } from '../journal/index.js';
|
|
8
|
+
import { Conflict, InvalidRequest } from '../system/errors.js';
|
|
9
|
+
import { shownTitle } from '../system/store.js';
|
|
10
|
+
import { Accounts } from './machine/accounts.js';
|
|
11
|
+
import { Moves } from './machine/moves.js';
|
|
12
|
+
import { Input } from './machine/input.js';
|
|
13
|
+
import { Lifecycle } from './machine/lifecycle.js';
|
|
14
|
+
import { CHAT_SIZE, ID, MachineState, UUID, codexOf } from './machine/state.js';
|
|
15
|
+
import { Status } from './machine/status.js';
|
|
16
|
+
import { Outbox, migrateQueueFile } from './outbox.js';
|
|
17
|
+
import { idOfTmuxName, tmuxName } from './sessionName.js';
|
|
18
|
+
export * from './machine/types.js';
|
|
19
|
+
// A message held back because a dialog, not the prompt, would have taken it.
|
|
20
|
+
class DialogOpen extends Error {
|
|
21
|
+
}
|
|
22
|
+
// A message held back because Claude quit, and the shell it left behind would run it.
|
|
23
|
+
class AgentQuit extends Error {
|
|
24
|
+
}
|
|
25
|
+
// The sessions on this Mac. The work is done by its parts in machine/; this holds them together, lists the
|
|
26
|
+
// sessions, and is what the rest of the server talks to.
|
|
27
|
+
export class LocalMachine {
|
|
28
|
+
id = 'local';
|
|
29
|
+
asks = new PermissionAsks((id) => this.journal.asks(id, this.asks.list(id)));
|
|
30
|
+
// Everything a session's page shows, as rows a copy catches up on (journal/), the outbox's messages too.
|
|
31
|
+
journal;
|
|
32
|
+
s = new MachineState();
|
|
33
|
+
d;
|
|
34
|
+
p;
|
|
35
|
+
// Per session, the last message still being taken in.
|
|
36
|
+
sending = new Map();
|
|
37
|
+
lastIds = '';
|
|
38
|
+
constructor(deps) {
|
|
39
|
+
const claude = new ClaudeScreen(deps.tmux, CHAT_SIZE.cols);
|
|
40
|
+
if (!sqliteAvailable())
|
|
41
|
+
throw new Error(`WorkOffDesk needs Node 22.13 or later (this is ${process.versions.node}).`);
|
|
42
|
+
this.journal = new Journal({
|
|
43
|
+
file: ':memory:',
|
|
44
|
+
log: () => { },
|
|
45
|
+
...deps.journal,
|
|
46
|
+
root: deps.claudeProjectsRoot,
|
|
47
|
+
titled: (id, found, fresh) => this.p.lifecycle.conversationNamed(id, found, fresh),
|
|
48
|
+
});
|
|
49
|
+
migrateQueueFile(deps.queueFile ?? join(dirname(deps.store.file), 'queue.json'), this.journal);
|
|
50
|
+
const outbox = new Outbox({
|
|
51
|
+
store: this.journal,
|
|
52
|
+
status: (id) => this.getStatus(id),
|
|
53
|
+
// Claude is at work on it from the moment it goes in: the next waits until it is done, not until a
|
|
54
|
+
// hook says it started.
|
|
55
|
+
deliver: async (id, text, qid) => {
|
|
56
|
+
if (deps.store.get().sessions[id]?.kind === 'codex')
|
|
57
|
+
return codexOf(this.d).send(id, text, qid);
|
|
58
|
+
if (!(await this.p.status.agentRuns(id)))
|
|
59
|
+
throw new AgentQuit();
|
|
60
|
+
if (await this.p.input.dialogOpen(id))
|
|
61
|
+
throw new DialogOpen();
|
|
62
|
+
await this.p.input.paste(id, text);
|
|
63
|
+
// A slash command may only open a dialog, which no hook reports the end of.
|
|
64
|
+
if (!text.startsWith('/') && (this.s.statuses.get(id) === 'idle' || !this.s.statuses.has(id)))
|
|
65
|
+
this.setStatus(id, 'working');
|
|
66
|
+
},
|
|
67
|
+
changed: () => this.s.emit(),
|
|
68
|
+
failed: (id, e) => e instanceof DialogOpen || e instanceof AgentQuit || console.error(`workoffdesk: a message to session ${id} could not go and waits: ${e instanceof Error ? e.message : String(e)}`),
|
|
69
|
+
});
|
|
70
|
+
const shell = deps.shell ?? process.env.SHELL ?? '/bin/zsh';
|
|
71
|
+
const log = deps.journal?.log ?? (() => { });
|
|
72
|
+
const codex = deps.codex
|
|
73
|
+
? new CodexAgent({ tmux: deps.tmux, sock: deps.codex.sock, codexBin: deps.codex.bin, shell }, {
|
|
74
|
+
journal: this.journal,
|
|
75
|
+
sessions: () => Object.entries(deps.store.get().sessions).flatMap(([id, m]) => (m.kind === 'codex' ? [{ id, cwd: m.cwd, createdAt: m.createdAt, threadId: m.codexThreadId }] : [])),
|
|
76
|
+
setThread: (id, threadId) => deps.store.update((st) => void (st.sessions[id] && (st.sessions[id].codexThreadId = threadId))),
|
|
77
|
+
moved: (id, status, said) => this.p.status.moved(id, status, said),
|
|
78
|
+
named: (id, name) => this.p.lifecycle.conversationNamed(id, { ai: name }, false),
|
|
79
|
+
setUsage: (id, usage) => this.setUsage(id, usage),
|
|
80
|
+
asksChanged: (id) => {
|
|
81
|
+
this.journal.asks(id, this.d.codex?.asks(id) ?? []);
|
|
82
|
+
this.s.emit();
|
|
83
|
+
},
|
|
84
|
+
log: (msg, o) => log(msg, o),
|
|
85
|
+
})
|
|
86
|
+
: null;
|
|
87
|
+
this.d = {
|
|
88
|
+
tmux: deps.tmux,
|
|
89
|
+
claude,
|
|
90
|
+
store: deps.store,
|
|
91
|
+
journal: this.journal,
|
|
92
|
+
outbox,
|
|
93
|
+
asks: this.asks,
|
|
94
|
+
codex,
|
|
95
|
+
codexThreads: codex && deps.codex ? new CodexThreads({ agent: codex, sock: deps.codex.sock, codexHome: deps.codex.home }) : null,
|
|
96
|
+
accounts: deps.claudeAccounts ?? null,
|
|
97
|
+
claudeSettingsPath: deps.claudeSettingsPath,
|
|
98
|
+
claudeBin: deps.claudeBin ?? 'claude',
|
|
99
|
+
shell,
|
|
100
|
+
claudeArgs: deps.claudeArgs ?? [],
|
|
101
|
+
claudeProjectsRoot: deps.claudeProjectsRoot,
|
|
102
|
+
};
|
|
103
|
+
const p = {};
|
|
104
|
+
p.sessions = this;
|
|
105
|
+
p.lifecycle = new Lifecycle(this.s, this.d, p);
|
|
106
|
+
p.input = new Input(this.s, this.d, p);
|
|
107
|
+
p.status = new Status(this.s, this.d, p);
|
|
108
|
+
p.accounts = new Accounts(this.d, p);
|
|
109
|
+
p.moves = new Moves(this.s, this.d, p);
|
|
110
|
+
this.p = p;
|
|
111
|
+
}
|
|
112
|
+
async codexOnMac() {
|
|
113
|
+
return this.d.codexThreads ? this.d.codexThreads.list() : [];
|
|
114
|
+
}
|
|
115
|
+
codexChat(threadId) {
|
|
116
|
+
if (!UUID.test(threadId))
|
|
117
|
+
throw new InvalidRequest('thread id must be a uuid');
|
|
118
|
+
return codexOf(this.d).readItems(threadId);
|
|
119
|
+
}
|
|
120
|
+
// What Codex said last in a session, for the list.
|
|
121
|
+
lastReply(id) {
|
|
122
|
+
return this.d.codex?.lastReply(id) ?? null;
|
|
123
|
+
}
|
|
124
|
+
// Where a change of status is told, for the sessions whose agent the server follows itself (Codex).
|
|
125
|
+
setNotifier(n) {
|
|
126
|
+
this.s.notifier = n;
|
|
127
|
+
}
|
|
128
|
+
// Which agents a session can be started with here, besides Claude Code.
|
|
129
|
+
get agents() {
|
|
130
|
+
return { codex: !!this.d.codex };
|
|
131
|
+
}
|
|
132
|
+
// A message as the journal has it, to answer what was done to it with.
|
|
133
|
+
message(id, qid) {
|
|
134
|
+
return this.journal.message(id, qid);
|
|
135
|
+
}
|
|
136
|
+
// Stops what writes to the journal, then the journal.
|
|
137
|
+
closeJournal() {
|
|
138
|
+
this.d.codex?.close();
|
|
139
|
+
for (const stop of this.s.transcriptWatches.values())
|
|
140
|
+
stop();
|
|
141
|
+
this.s.transcriptWatches.clear();
|
|
142
|
+
this.s.stopTimers();
|
|
143
|
+
this.journal.close();
|
|
144
|
+
}
|
|
145
|
+
// Takes what the session's conversation added into the journal.
|
|
146
|
+
ingest(id) {
|
|
147
|
+
return this.journal.ingest(id, this.d.store.get().sessions[id]?.claudeSessionId ?? null);
|
|
148
|
+
}
|
|
149
|
+
// Metadata is written only after tmux has the session, and the ids that may be
|
|
150
|
+
// pruned are read before asking tmux; so a missing id here means tmux lost it.
|
|
151
|
+
async listSessions() {
|
|
152
|
+
const known = Object.keys(this.d.store.get().sessions);
|
|
153
|
+
void this.d.accounts?.list().catch(() => { });
|
|
154
|
+
const live = (await this.d.tmux.listSessions()).flatMap((t) => {
|
|
155
|
+
const id = idOfTmuxName(t.name);
|
|
156
|
+
return id === undefined ? [] : [{ ...t, id }];
|
|
157
|
+
});
|
|
158
|
+
const liveIds = new Set(live.map((t) => t.id));
|
|
159
|
+
const stale = known.filter((id) => !liveIds.has(id));
|
|
160
|
+
if (stale.length) {
|
|
161
|
+
this.d.store.update((st) => stale.forEach((id) => delete st.sessions[id]));
|
|
162
|
+
stale.forEach((id) => this.p.status.forget(id));
|
|
163
|
+
}
|
|
164
|
+
const sessions = live.map(({ id, ...t }) => {
|
|
165
|
+
const meta = this.d.store.get().sessions[id] ?? {
|
|
166
|
+
title: t.name,
|
|
167
|
+
kind: 'shell',
|
|
168
|
+
cwd: '',
|
|
169
|
+
createdAt: t.created,
|
|
170
|
+
};
|
|
171
|
+
return { id, meta, activity: t.activity, panePid: t.panePid };
|
|
172
|
+
});
|
|
173
|
+
await this.p.status.followClaudes(sessions.flatMap((s) => (s.meta.kind === 'claude' && s.panePid ? [{ id: s.id, panePid: s.panePid }] : [])));
|
|
174
|
+
// A status not known yet is read before it is told: Claude may be at work behind its prompt.
|
|
175
|
+
await Promise.all(sessions.map((s) => s.meta.kind === 'claude' && this.p.status.fillStatus(s.id)));
|
|
176
|
+
for (const s of sessions)
|
|
177
|
+
if (s.meta.kind === 'claude')
|
|
178
|
+
this.p.status.look(s.id);
|
|
179
|
+
for (const s of sessions)
|
|
180
|
+
if (s.meta.kind === 'codex')
|
|
181
|
+
this.p.status.lookCodex(s.id, s.meta);
|
|
182
|
+
const ids = [...liveIds].sort().join(',');
|
|
183
|
+
if (ids !== this.lastIds) {
|
|
184
|
+
this.lastIds = ids;
|
|
185
|
+
this.s.emit();
|
|
186
|
+
}
|
|
187
|
+
return sessions.map((s) => this.toSession(s.id, s.meta, s.activity)).sort((a, b) => b.lastActivity - a.lastActivity);
|
|
188
|
+
}
|
|
189
|
+
async exists(id) {
|
|
190
|
+
return ID.test(id) && this.d.tmux.hasSession(tmuxName(id));
|
|
191
|
+
}
|
|
192
|
+
// Taken in the order they arrive: each waits until the one before it went in or was queued.
|
|
193
|
+
sendMessage(id, text, qid) {
|
|
194
|
+
if (!ID.test(id))
|
|
195
|
+
return Promise.reject(new InvalidRequest('bad session id'));
|
|
196
|
+
const taken = (this.sending.get(id) ?? Promise.resolve()).then(async () => {
|
|
197
|
+
// A compaction in a session that reports none shows only on screen.
|
|
198
|
+
await this.p.input.onScreen(id).catch(() => { });
|
|
199
|
+
// Nor does a status not known yet go for idle: Claude may be at work behind its prompt.
|
|
200
|
+
if (this.d.store.get().sessions[id]?.kind === 'claude')
|
|
201
|
+
await this.p.status.fillStatus(id);
|
|
202
|
+
return this.d.outbox.send(id, text, qid);
|
|
203
|
+
});
|
|
204
|
+
const last = taken.catch(() => { });
|
|
205
|
+
this.sending.set(id, last);
|
|
206
|
+
void last.then(() => {
|
|
207
|
+
if (this.sending.get(id) === last)
|
|
208
|
+
this.sending.delete(id);
|
|
209
|
+
});
|
|
210
|
+
return taken;
|
|
211
|
+
}
|
|
212
|
+
unsend(id, queuedId) {
|
|
213
|
+
return this.d.outbox.remove(id, queuedId);
|
|
214
|
+
}
|
|
215
|
+
async sendNow(id, queuedId) {
|
|
216
|
+
if (!ID.test(id))
|
|
217
|
+
throw new InvalidRequest('bad session id');
|
|
218
|
+
if (await this.p.input.dialogOpen(id))
|
|
219
|
+
throw new Conflict('a dialog is open; pick an option or press Esc first', 'dialog-open');
|
|
220
|
+
return this.d.outbox.sendNow(id, queuedId);
|
|
221
|
+
}
|
|
222
|
+
moveQueued(id, queuedId, by) {
|
|
223
|
+
return this.d.outbox.move(id, queuedId, by);
|
|
224
|
+
}
|
|
225
|
+
createSession(spec) { return this.p.lifecycle.createSession(spec); }
|
|
226
|
+
continueSession(id) { return this.p.lifecycle.continueSession(id); }
|
|
227
|
+
openOnMac() { return this.p.lifecycle.openOnMac(); }
|
|
228
|
+
takeOverWhenIdle(spec) { return this.p.lifecycle.takeOverWhenIdle(spec); }
|
|
229
|
+
takingOver(claudeSessionId) { return this.p.lifecycle.takingOver(claudeSessionId); }
|
|
230
|
+
cancelTakeOver(claudeSessionId) { return this.p.lifecycle.cancelTakeOver(claudeSessionId); }
|
|
231
|
+
killSession(id) { return this.p.lifecycle.killSession(id); }
|
|
232
|
+
renameSession(id, title) { return this.p.lifecycle.renameSession(id, title); }
|
|
233
|
+
claudeName(claudeSessionId) { return this.p.lifecycle.claudeName(claudeSessionId); }
|
|
234
|
+
choices(id) { return this.p.input.choices(id); }
|
|
235
|
+
onScreen(id) { return this.p.input.onScreen(id); }
|
|
236
|
+
screenText(id) { return this.p.input.screenText(id); }
|
|
237
|
+
choose(id, index, label, text) { return this.p.input.choose(id, index, label, text); }
|
|
238
|
+
tick(id, ticked, labels) { return this.p.input.tick(id, ticked, labels); }
|
|
239
|
+
holdPermission(id, payload, hook) { return this.p.input.holdPermission(id, payload, hook); }
|
|
240
|
+
answer(id, askId, answer) { return this.p.input.answer(id, askId, answer); }
|
|
241
|
+
type(id, text) { return this.p.input.type(id, text); }
|
|
242
|
+
close(id) { return this.p.input.close(id); }
|
|
243
|
+
press(id, keys) { return this.p.input.press(id, keys); }
|
|
244
|
+
runCommand(id, command) { return this.p.input.runCommand(id, command); }
|
|
245
|
+
fitForChat(id) { return this.p.input.fitForChat(id); }
|
|
246
|
+
getStatus(id) { return this.p.status.getStatus(id); }
|
|
247
|
+
setStatus(id, status) { this.p.status.setStatus(id, status); }
|
|
248
|
+
claudeMayHaveQuit(id) { this.p.status.claudeMayHaveQuit(id); }
|
|
249
|
+
compactStarted(id) { this.p.status.compactStarted(id); }
|
|
250
|
+
compactEnded(id, manual) { this.p.status.compactEnded(id, manual); }
|
|
251
|
+
setUsage(id, usage) { this.p.status.setUsage(id, usage); }
|
|
252
|
+
setClaudeSessionId(id, claudeSessionId) { this.p.status.setClaudeSessionId(id, claudeSessionId); }
|
|
253
|
+
claudeAccountsView() { return this.p.accounts.claudeAccountsView(); }
|
|
254
|
+
pickClaudeAccount(id) { return this.p.accounts.pickClaudeAccount(id); }
|
|
255
|
+
async moveToAccountInUse() {
|
|
256
|
+
await this.p.moves.moveAll();
|
|
257
|
+
return this.p.accounts.claudeAccountsView();
|
|
258
|
+
}
|
|
259
|
+
sessionMeta(id) {
|
|
260
|
+
return this.d.store.get().sessions[id];
|
|
261
|
+
}
|
|
262
|
+
onChange(cb) {
|
|
263
|
+
this.s.listeners.add(cb);
|
|
264
|
+
return () => this.s.listeners.delete(cb);
|
|
265
|
+
}
|
|
266
|
+
// One listing at a time: the next starts an interval after the last one ends, so a slow tmux
|
|
267
|
+
// makes the watch slower instead of piling listings onto it.
|
|
268
|
+
watch(intervalMs) {
|
|
269
|
+
let stopped = false;
|
|
270
|
+
let timer;
|
|
271
|
+
const next = () => {
|
|
272
|
+
if (stopped)
|
|
273
|
+
return;
|
|
274
|
+
timer = setTimeout(() => {
|
|
275
|
+
this.listSessions().catch(() => { }).finally(next);
|
|
276
|
+
}, intervalMs);
|
|
277
|
+
timer.unref();
|
|
278
|
+
};
|
|
279
|
+
next();
|
|
280
|
+
return () => {
|
|
281
|
+
stopped = true;
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
toSession(id, meta, lastActivity) {
|
|
286
|
+
return {
|
|
287
|
+
id,
|
|
288
|
+
title: shownTitle(meta),
|
|
289
|
+
kind: meta.kind,
|
|
290
|
+
cwd: meta.cwd,
|
|
291
|
+
claudeSessionId: meta.claudeSessionId,
|
|
292
|
+
codexThreadId: meta.codexThreadId,
|
|
293
|
+
createdAt: meta.createdAt,
|
|
294
|
+
lastActivity,
|
|
295
|
+
status: meta.kind === 'shell' ? null : (this.s.statuses.get(id) ?? 'idle'),
|
|
296
|
+
usage: this.s.usages.get(id) ?? null,
|
|
297
|
+
claudeAccount: this.p.accounts.accountLabel(meta),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|