workoffdesk 1.0.0 → 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 +27 -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/http/sessionSocket.js +12 -11
- 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-DinzDcF1.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-w7i8PvU7.js +0 -132
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { findTranscript } from '../../agents/claude/transcript/transcriptFile.js';
|
|
4
|
-
import {
|
|
4
|
+
import { findMacClaude, listMacClaudes, MacUnanswered, openStatus, stopMacProcess } from '../../agents/claude/terminals.js';
|
|
5
5
|
import { tuiCommand } from '../../agents/codex/appServer.js';
|
|
6
6
|
import { expandHome, isDir } from '../../system/dirs.js';
|
|
7
7
|
import { Conflict, InvalidRequest, NotFound } from '../../system/errors.js';
|
|
@@ -14,9 +14,6 @@ import { CHAT_SIZE, ID, UUID, codexOf, nameConversation, nameLine, sessionTitle
|
|
|
14
14
|
const STARTING_MAX_MS = 20_000;
|
|
15
15
|
// How long a Claude asked to quit, to start again on another account, is waited for.
|
|
16
16
|
const MOVE_QUIT_MS = 5_000;
|
|
17
|
-
// How often a conversation waited on is looked at on the Mac, and for how long at most.
|
|
18
|
-
const TAKE_OVER_LOOK_MS = 2_000;
|
|
19
|
-
const TAKE_OVER_WAIT_MS = 12 * 3600_000;
|
|
20
17
|
// A program in the alternate screen keeps its output out of the scrollback, where a dialog taller than the screen starts.
|
|
21
18
|
// Claude Code goes there by default; this keeps it inline, for claude typed into a shell session too.
|
|
22
19
|
const KEEP_OUTPUT_IN_SCROLLBACK = { CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: '1' };
|
|
@@ -31,8 +28,6 @@ export class Lifecycle {
|
|
|
31
28
|
s;
|
|
32
29
|
d;
|
|
33
30
|
p;
|
|
34
|
-
// Per Claude conversation waited on to be idle on the Mac, the way to stop waiting.
|
|
35
|
-
takeOvers = new Map();
|
|
36
31
|
constructor(s, d, p) {
|
|
37
32
|
this.s = s;
|
|
38
33
|
this.d = d;
|
|
@@ -50,7 +45,12 @@ export class Lifecycle {
|
|
|
50
45
|
async continueSession(id) {
|
|
51
46
|
if (!ID.test(id))
|
|
52
47
|
throw new InvalidRequest('bad session id');
|
|
53
|
-
const
|
|
48
|
+
const past = this.d.store.get().past[id];
|
|
49
|
+
if (past?.kind === 'codex' && past.codexThreadId) {
|
|
50
|
+
const tid = past.codexThreadId;
|
|
51
|
+
return this.resumeOnce(tid, () => this.resumeCodex({ kind: 'codex', cwd: past.cwd, resumeId: tid }));
|
|
52
|
+
}
|
|
53
|
+
const meta = past ?? this.d.store.get().sessions[id];
|
|
54
54
|
if (!meta)
|
|
55
55
|
throw new NotFound('no such session');
|
|
56
56
|
const sid = meta.claudeSessionId;
|
|
@@ -72,7 +72,7 @@ export class Lifecycle {
|
|
|
72
72
|
// Claude quit holding it goes on with it (`into`, when asked of that one), rather than a second session.
|
|
73
73
|
async resumeClaude(spec, into) {
|
|
74
74
|
if (spec.takeOver)
|
|
75
|
-
this.
|
|
75
|
+
this.p.takeOvers.cancel(spec.resumeId);
|
|
76
76
|
const here = (await this.p.sessions.listSessions()).filter((s) => s.claudeSessionId === spec.resumeId);
|
|
77
77
|
// Asked of its process: the listing does not look at a session while it is gone on with.
|
|
78
78
|
for (const s of here)
|
|
@@ -93,6 +93,11 @@ export class Lifecycle {
|
|
|
93
93
|
}
|
|
94
94
|
if (!quit)
|
|
95
95
|
return this.startSession(spec, id);
|
|
96
|
+
if (quit.past) {
|
|
97
|
+
if (this.d.claudeProjectsRoot && !(await findTranscript(this.d.claudeProjectsRoot, spec.resumeId).catch(() => null)))
|
|
98
|
+
throw conversationGone('Claude');
|
|
99
|
+
return this.revive(id, spec);
|
|
100
|
+
}
|
|
96
101
|
// The shell its Claude left is not ended while something runs in it.
|
|
97
102
|
const pane = await this.d.tmux.panePid(tmuxName(id));
|
|
98
103
|
if (pane && (await shellBusy(pane)))
|
|
@@ -100,9 +105,10 @@ export class Lifecycle {
|
|
|
100
105
|
return this.restartClaude(id, spec);
|
|
101
106
|
}
|
|
102
107
|
// A session's Claude quit and started again in its pane on the account in use, going on with its conversation, as
|
|
103
|
-
// one resume of it, so nothing takes the moment between the two for Claude having quit.
|
|
108
|
+
// one resume of it, so nothing takes the moment between the two for Claude having quit. The Claudes in terminals on
|
|
109
|
+
// the Mac holding the conversation are quit first (quitOnMac, its refusals as they are). One whose Claude does not
|
|
104
110
|
// quit, or could not start again for its folder being gone, is left as it was.
|
|
105
|
-
moveClaude(id) {
|
|
111
|
+
moveClaude(id, forceMac = false) {
|
|
106
112
|
const meta = this.d.store.get().sessions[id];
|
|
107
113
|
const sid = meta?.claudeSessionId;
|
|
108
114
|
if (!sid)
|
|
@@ -111,28 +117,54 @@ export class Lifecycle {
|
|
|
111
117
|
return Promise.reject(folderGone(meta.cwd));
|
|
112
118
|
return this.resumeOnce(sid, async () => {
|
|
113
119
|
this.p.status.setStatus(id, 'starting');
|
|
114
|
-
const
|
|
115
|
-
if (!pane || !(await quitAgent(pane, MOVE_QUIT_MS))) {
|
|
120
|
+
const stay = (e) => {
|
|
116
121
|
this.s.statuses.delete(id);
|
|
117
122
|
void this.p.status.fillStatus(id);
|
|
118
|
-
throw
|
|
119
|
-
}
|
|
123
|
+
throw e;
|
|
124
|
+
};
|
|
125
|
+
await this.quitOnMac(sid, id, forceMac).catch(stay);
|
|
126
|
+
const pane = await this.d.tmux.panePid(tmuxName(id)).catch(() => undefined);
|
|
127
|
+
if (!pane || !(await quitAgent(pane, MOVE_QUIT_MS)))
|
|
128
|
+
stay(new Error(`its Claude did not quit within ${MOVE_QUIT_MS / 1000} seconds, and it stays on its account`));
|
|
120
129
|
return this.restartClaude(id, { resumeId: sid });
|
|
121
130
|
});
|
|
122
131
|
}
|
|
132
|
+
async quitOthersOnMac(id, force = false) {
|
|
133
|
+
const meta = this.d.store.get().sessions[id];
|
|
134
|
+
if (!meta)
|
|
135
|
+
throw new NotFound('no such session');
|
|
136
|
+
if (meta.claudeSessionId)
|
|
137
|
+
await this.quitOnMac(meta.claudeSessionId, id, force);
|
|
138
|
+
}
|
|
139
|
+
// Each Claude in a terminal on the Mac holding a conversation quit, and told the session it went on in; unless
|
|
140
|
+
// force, none quit while one there is at work or asking. The sessions here then learn it is open there no longer.
|
|
141
|
+
async quitOnMac(sid, goneTo, force = false) {
|
|
142
|
+
const there = (await this.openOnMac()).get(sid);
|
|
143
|
+
if (!there)
|
|
144
|
+
return;
|
|
145
|
+
if (!force && there.status !== 'idle')
|
|
146
|
+
throw new Conflict(m.moveThis.macBusy, 'open-on-mac-busy');
|
|
147
|
+
const stopped = await Promise.all(there.claudes.map((c) => stopMacProcess(c, m.takeOver.goneToPhone(goneTo))));
|
|
148
|
+
await this.openOnMac();
|
|
149
|
+
if (stopped.includes(false))
|
|
150
|
+
throw new Conflict(m.takeOver.wouldNotQuit('Claude'), 'mac-would-not-quit');
|
|
151
|
+
}
|
|
123
152
|
// As for Claude, by the thread's writer lock: whoever holds it writes the thread, and Codex lets no one else.
|
|
124
153
|
// A Codex in a terminal is quit there first; another app's is left alone, and the thread is not resumed.
|
|
125
154
|
// The taken-over Codex's environment stays behind: its commands run in this server's app server.
|
|
126
155
|
async resumeCodex(spec) {
|
|
127
156
|
if (spec.takeOver)
|
|
128
|
-
this.
|
|
157
|
+
this.p.takeOvers.cancel(spec.resumeId);
|
|
129
158
|
const here = (await this.p.sessions.listSessions()).find((s) => s.codexThreadId === spec.resumeId);
|
|
130
|
-
if (here)
|
|
159
|
+
if (here && !here.past)
|
|
131
160
|
return here;
|
|
132
161
|
if (!isDir(expandHome(spec.cwd, homedir())))
|
|
133
162
|
throw folderGone(spec.cwd);
|
|
134
|
-
const
|
|
135
|
-
|
|
163
|
+
const thread = await this.codexWhere(spec.resumeId);
|
|
164
|
+
if (here && !thread)
|
|
165
|
+
throw conversationGone('Codex');
|
|
166
|
+
const holder = thread?.holder;
|
|
167
|
+
const id = here?.id ?? newId();
|
|
136
168
|
if (holder?.at === 'elsewhere')
|
|
137
169
|
throw new Conflict(m.takeOver.openElsewhere, 'open-elsewhere');
|
|
138
170
|
if (holder?.at === 'terminal') {
|
|
@@ -141,72 +173,56 @@ export class Lifecycle {
|
|
|
141
173
|
if (!(await stopMacProcess(holder, m.takeOver.goneToPhone(id))))
|
|
142
174
|
throw new Conflict(m.takeOver.wouldNotQuit('Codex'), 'mac-would-not-quit');
|
|
143
175
|
}
|
|
144
|
-
|
|
176
|
+
const codex = { kind: 'codex', cwd: spec.cwd, resumeId: spec.resumeId, title: spec.title };
|
|
177
|
+
return here ? this.revive(id, codex) : this.startSession(codex, id);
|
|
178
|
+
}
|
|
179
|
+
// A past session started again in place, going on with its conversation: the same id, name and journal. A name
|
|
180
|
+
// given it while it was past is given its agent's conversation too.
|
|
181
|
+
async revive(id, spec) {
|
|
182
|
+
const was = this.d.store.get().past[id];
|
|
183
|
+
const started = await this.startSession(spec, id);
|
|
184
|
+
this.d.store.update((st) => {
|
|
185
|
+
delete st.past[id];
|
|
186
|
+
const now = st.sessions[id];
|
|
187
|
+
if (!now)
|
|
188
|
+
return;
|
|
189
|
+
const { title, named, customTitle, aiTitle, createdAt, pinned } = was;
|
|
190
|
+
Object.assign(now, { title, named, customTitle, aiTitle, createdAt, pinned });
|
|
191
|
+
if (named && (spec.kind === 'claude' && spec.resumeId ? this.claudeName(spec.resumeId) : null) !== title)
|
|
192
|
+
now.naming = title;
|
|
193
|
+
});
|
|
194
|
+
this.p.naming.watch();
|
|
195
|
+
return (await this.p.sessions.listSessions()).find((s) => s.id === id) ?? started;
|
|
145
196
|
}
|
|
146
197
|
codexWhere(threadId) {
|
|
147
198
|
if (!this.d.codexThreads)
|
|
148
199
|
throw new InvalidRequest('Codex is not set up on this Mac');
|
|
149
200
|
return this.d.codexThreads.where(threadId);
|
|
150
201
|
}
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return (await this.openOnMac()).get(spec.resumeId)?.status;
|
|
155
|
-
const t = await this.codexWhere(spec.resumeId);
|
|
156
|
-
return t?.holder?.at === 'terminal' ? (t.busy ? 'busy' : 'idle') : undefined;
|
|
157
|
-
}
|
|
158
|
-
// Each session here learns from it whether its conversation is open on the Mac too.
|
|
159
|
-
async openOnMac() {
|
|
202
|
+
// Each session here learns from it whether its conversation is open on the Mac too. recentMs: as
|
|
203
|
+
// listMacClaudes takes it, for what is only shown.
|
|
204
|
+
async openOnMac(recentMs = 0) {
|
|
160
205
|
const open = new Map();
|
|
161
|
-
|
|
206
|
+
const listed = await listMacClaudes(this.d.claudeBin, recentMs).catch((e) => {
|
|
207
|
+
throw e instanceof MacUnanswered ? new Conflict(m.takeOver.macUnanswered, 'mac-unanswered') : e;
|
|
208
|
+
});
|
|
209
|
+
for (const c of listed)
|
|
162
210
|
open.set(c.sessionId, [...(open.get(c.sessionId) ?? []), c]);
|
|
163
211
|
for (const [id, meta] of Object.entries(this.d.store.get().sessions)) {
|
|
164
212
|
if (meta.kind === 'claude' && meta.claudeSessionId)
|
|
165
213
|
this.d.journal.onMac(id, open.get(meta.claudeSessionId)?.length ?? 0);
|
|
166
214
|
}
|
|
167
|
-
|
|
215
|
+
const transcript = (sid) => (this.d.claudeProjectsRoot ? findTranscript(this.d.claudeProjectsRoot, sid).catch(() => null) : null);
|
|
216
|
+
return new Map(await Promise.all([...open].map(async ([sid, list]) => [sid, { claudes: list, status: await openStatus(list, () => transcript(sid)) }])));
|
|
168
217
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const there = await this.onMacStatus(spec);
|
|
174
|
-
const takeOver = spec.kind === 'claude' ? { ...spec, kind: 'claude', takeOver: true } : { ...spec, kind: 'codex', takeOver: true };
|
|
175
|
-
if (!there || there === 'idle')
|
|
176
|
-
return this.createSession(takeOver);
|
|
177
|
-
const until = Date.now() + TAKE_OVER_WAIT_MS;
|
|
178
|
-
let stop;
|
|
179
|
-
const done = () => {
|
|
180
|
-
stop();
|
|
181
|
-
this.takeOvers.delete(sid);
|
|
182
|
-
this.s.emit();
|
|
183
|
-
};
|
|
184
|
-
const look = async () => {
|
|
185
|
-
const now = await this.onMacStatus(spec).catch(() => there);
|
|
186
|
-
if (this.takeOvers.get(sid) !== done)
|
|
187
|
-
return;
|
|
188
|
-
if (now && now !== 'idle') {
|
|
189
|
-
if (Date.now() > until)
|
|
190
|
-
return done();
|
|
191
|
-
stop = this.s.later(TAKE_OVER_LOOK_MS, () => void look());
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
done();
|
|
195
|
-
await this.createSession(takeOver).catch((e) => console.error(`workoffdesk: conversation ${sid} could not be taken over from the Mac: ${e instanceof Error ? e.message : String(e)}`));
|
|
196
|
-
};
|
|
197
|
-
this.takeOvers.set(sid, done);
|
|
198
|
-
stop = this.s.later(TAKE_OVER_LOOK_MS, () => void look());
|
|
199
|
-
this.s.emit();
|
|
200
|
-
return null;
|
|
218
|
+
macClaudeOf(pid) {
|
|
219
|
+
return findMacClaude(this.d.claudeBin, pid).catch((e) => {
|
|
220
|
+
throw e instanceof MacUnanswered ? new Conflict(m.takeOver.macUnanswered, 'mac-unanswered') : e;
|
|
221
|
+
});
|
|
201
222
|
}
|
|
202
223
|
// Waited on, or being taken over now (Claude on the Mac quitting, the session here starting).
|
|
203
|
-
takingOver(
|
|
204
|
-
return this.takeOvers.
|
|
205
|
-
}
|
|
206
|
-
cancelTakeOver(claudeSessionId) {
|
|
207
|
-
const done = this.takeOvers.get(claudeSessionId);
|
|
208
|
-
done?.();
|
|
209
|
-
return !!done;
|
|
224
|
+
takingOver(sid) {
|
|
225
|
+
return this.p.takeOvers.waiting(sid) || this.s.resuming.has(sid);
|
|
210
226
|
}
|
|
211
227
|
async startSession(spec, id) {
|
|
212
228
|
const cwd = spec.cwd ? expandHome(spec.cwd, homedir()) : homedir();
|
|
@@ -216,6 +232,9 @@ export class Lifecycle {
|
|
|
216
232
|
const resumed = spec.kind !== 'shell' && spec.resumeId;
|
|
217
233
|
const given = resumed ? ((spec.kind === 'claude' && this.claudeName(resumed)) || undefined) : spec.title;
|
|
218
234
|
const meta = { title: sessionTitle(given, cwd), named: !!nameLine(given), kind: spec.kind, cwd, createdAt: Date.now() };
|
|
235
|
+
// Claude is started with it; Codex's thread is named once it is known.
|
|
236
|
+
if (spec.kind === 'codex' && meta.named)
|
|
237
|
+
meta.naming = meta.title;
|
|
219
238
|
let command;
|
|
220
239
|
if (spec.kind === 'codex') {
|
|
221
240
|
const codex = codexOf(this.d);
|
|
@@ -226,7 +245,7 @@ export class Lifecycle {
|
|
|
226
245
|
}
|
|
227
246
|
if (spec.kind === 'claude') {
|
|
228
247
|
meta.claudeSessionId = spec.resumeId ?? randomUUID();
|
|
229
|
-
const claude = await this.claudeCommand(spec.resumeId ? { resume: spec.resumeId } : { sessionId: meta.claudeSessionId }, spec.env);
|
|
248
|
+
const claude = await this.claudeCommand(spec.resumeId ? { resume: spec.resumeId } : { sessionId: meta.claudeSessionId }, spec.env, nameLine(given) || undefined);
|
|
230
249
|
command = claude.command;
|
|
231
250
|
if (claude.dir !== undefined)
|
|
232
251
|
meta.claudeDir = claude.dir;
|
|
@@ -248,9 +267,9 @@ export class Lifecycle {
|
|
|
248
267
|
}
|
|
249
268
|
// What a Claude session's pane runs: Claude, then a login shell once it ends, however it ends (panes.ts). It runs on
|
|
250
269
|
// the account in use, or, started from a terminal (with that terminal's environment), on that terminal's; dir is
|
|
251
|
-
// that account, when known.
|
|
252
|
-
async claudeCommand(conversation, terminal) {
|
|
253
|
-
const idArgs = 'resume' in conversation ? `--resume ${shq(conversation.resume)}` : `--session-id ${shq(conversation.sessionId)}`;
|
|
270
|
+
// that account, when known. name: the name given the session here, which Claude takes for the conversation's.
|
|
271
|
+
async claudeCommand(conversation, terminal, name) {
|
|
272
|
+
const idArgs = `${'resume' in conversation ? `--resume ${shq(conversation.resume)}` : `--session-id ${shq(conversation.sessionId)}`}${name ? ` --name ${shq(name)}` : ''}`;
|
|
254
273
|
const account = terminal ? undefined : await this.p.accounts.accountInUse();
|
|
255
274
|
const dirArgs = account === undefined ? '' : account ? `env CLAUDE_CONFIG_DIR=${shq(account)} ` : 'env -u CLAUDE_CONFIG_DIR ';
|
|
256
275
|
const dir = terminal ? this.d.accounts?.idOf(terminal.CLAUDE_CONFIG_DIR) : (account ?? this.d.accounts?.ownId());
|
|
@@ -277,12 +296,15 @@ export class Lifecycle {
|
|
|
277
296
|
const said = this.d.claudeProjectsRoot ? await findTranscript(this.d.claudeProjectsRoot, spec.resumeId).catch(() => null) : true;
|
|
278
297
|
this.p.status.setStatus(id, 'starting');
|
|
279
298
|
try {
|
|
280
|
-
const
|
|
299
|
+
const given = meta.named === false ? undefined : nameLine(meta.title) || undefined;
|
|
300
|
+
const claude = await this.claudeCommand(said ? { resume: spec.resumeId } : { sessionId: spec.resumeId }, spec.env, given);
|
|
281
301
|
await this.d.tmux.respawnPane(name, { cwd: meta.cwd, command: claude.command, env: this.sessionEnv(id, spec.env) });
|
|
302
|
+
this.p.naming.taken(id);
|
|
282
303
|
this.d.store.update((st) => {
|
|
283
304
|
const now = st.sessions[id];
|
|
284
305
|
if (!now)
|
|
285
306
|
return;
|
|
307
|
+
delete now.naming;
|
|
286
308
|
if (claude.dir !== undefined)
|
|
287
309
|
now.claudeDir = claude.dir;
|
|
288
310
|
if (spec.env)
|
|
@@ -298,45 +320,89 @@ export class Lifecycle {
|
|
|
298
320
|
this.startingAtMost(id);
|
|
299
321
|
return (await this.p.sessions.listSessions()).find((s) => s.id === id) ?? this.p.sessions.toSession(id, meta, Date.now());
|
|
300
322
|
}
|
|
323
|
+
// Ends a running session, which keeps it as past; deletes a past one, and what its journal held.
|
|
301
324
|
async killSession(id) {
|
|
302
325
|
if (!ID.test(id))
|
|
303
326
|
throw new InvalidRequest('bad session id');
|
|
327
|
+
if (this.d.store.get().past[id]) {
|
|
328
|
+
this.d.store.update((st) => delete st.past[id]);
|
|
329
|
+
this.d.journal.drop(id);
|
|
330
|
+
this.s.emit();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
304
333
|
await this.d.tmux.killSession(tmuxName(id));
|
|
305
|
-
this.d.store.update((st) => delete st.sessions[id]);
|
|
306
|
-
this.p.status.forget(id);
|
|
307
334
|
await this.p.sessions.listSessions();
|
|
308
335
|
}
|
|
336
|
+
async pinSession(id, on) {
|
|
337
|
+
if (!ID.test(id))
|
|
338
|
+
throw new InvalidRequest('bad session id');
|
|
339
|
+
const st = this.d.store.get();
|
|
340
|
+
const meta = st.past[id] ?? ((await this.p.sessions.exists(id)) ? st.sessions[id] : undefined);
|
|
341
|
+
if (!meta)
|
|
342
|
+
return false;
|
|
343
|
+
if (!!meta.pinned === on)
|
|
344
|
+
return true;
|
|
345
|
+
this.d.store.update((next) => {
|
|
346
|
+
const now = next.past[id] ?? next.sessions[id];
|
|
347
|
+
if (!now)
|
|
348
|
+
return;
|
|
349
|
+
if (on)
|
|
350
|
+
now.pinned = Date.now();
|
|
351
|
+
else
|
|
352
|
+
delete now.pinned;
|
|
353
|
+
});
|
|
354
|
+
this.s.emit();
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
309
357
|
async renameSession(id, title) {
|
|
310
358
|
if (!ID.test(id))
|
|
311
359
|
throw new InvalidRequest('bad session id');
|
|
360
|
+
const name = nameLine(title);
|
|
361
|
+
if (!name)
|
|
362
|
+
throw new InvalidRequest('a name cannot be empty');
|
|
363
|
+
if (this.d.store.get().past[id]) {
|
|
364
|
+
this.d.store.update((st) => Object.assign(st.past[id], { title: name, named: true }));
|
|
365
|
+
this.s.emit();
|
|
366
|
+
return (await this.p.sessions.listSessions()).find((s) => s.id === id) ?? null;
|
|
367
|
+
}
|
|
312
368
|
const meta = this.d.store.get().sessions[id];
|
|
313
369
|
if (!meta || !(await this.p.sessions.exists(id)))
|
|
314
370
|
return null;
|
|
371
|
+
// The name its agent has, or is being given: nothing to give it again.
|
|
372
|
+
if ((meta.naming ?? meta.customTitle) === name)
|
|
373
|
+
return (await this.p.sessions.listSessions()).find((s) => s.id === id) ?? null;
|
|
315
374
|
this.d.store.update((st) => {
|
|
316
375
|
const now = st.sessions[id];
|
|
317
376
|
if (!now)
|
|
318
377
|
return;
|
|
319
|
-
now.title =
|
|
320
|
-
now.named =
|
|
378
|
+
now.title = name;
|
|
379
|
+
now.named = true;
|
|
380
|
+
now.naming = name;
|
|
321
381
|
if (now.claudeSessionId)
|
|
322
|
-
nameConversation(st, now.claudeSessionId,
|
|
382
|
+
nameConversation(st, now.claudeSessionId, name);
|
|
323
383
|
});
|
|
324
384
|
this.s.emit();
|
|
385
|
+
this.p.naming.watch();
|
|
325
386
|
return (await this.p.sessions.listSessions()).find((s) => s.id === id) ?? null;
|
|
326
387
|
}
|
|
327
388
|
// What the agent calls a session's conversation. Read from its start, the names it was given there;
|
|
328
389
|
// after, one given by /rename is given as one given here is, the latest name given being the one kept.
|
|
390
|
+
// While a name given here is on its way, only that name counts: any other is one the agent held before.
|
|
329
391
|
conversationNamed(id, found, fresh) {
|
|
330
392
|
const meta = this.d.store.get().sessions[id];
|
|
331
393
|
if (!meta)
|
|
332
394
|
return;
|
|
333
|
-
const
|
|
395
|
+
const written = nameLine(found.custom) || undefined;
|
|
396
|
+
const taken = !!meta.naming && written === meta.naming;
|
|
397
|
+
const custom = meta.naming && !taken ? undefined : written;
|
|
334
398
|
const ai = nameLine(found.ai) || undefined;
|
|
335
399
|
const customTitle = fresh ? custom : (custom ?? meta.customTitle);
|
|
336
400
|
const aiTitle = fresh ? ai : (ai ?? meta.aiTitle);
|
|
337
|
-
const renamed = !fresh && !!custom && meta.named !== false;
|
|
401
|
+
const renamed = taken || (!fresh && !!custom && meta.named !== false);
|
|
338
402
|
if (customTitle === meta.customTitle && aiTitle === meta.aiTitle && !renamed)
|
|
339
403
|
return;
|
|
404
|
+
if (taken)
|
|
405
|
+
this.p.naming.taken(id);
|
|
340
406
|
this.d.store.update((st) => {
|
|
341
407
|
const now = st.sessions[id];
|
|
342
408
|
if (!now)
|
|
@@ -345,6 +411,8 @@ export class Lifecycle {
|
|
|
345
411
|
now.aiTitle = aiTitle;
|
|
346
412
|
if (!renamed)
|
|
347
413
|
return;
|
|
414
|
+
if (taken)
|
|
415
|
+
delete now.naming;
|
|
348
416
|
now.named = false;
|
|
349
417
|
if (now.claudeSessionId)
|
|
350
418
|
nameConversation(st, now.claudeSessionId, '');
|
|
@@ -355,3 +423,5 @@ export class Lifecycle {
|
|
|
355
423
|
return this.d.store.get().claudeNames[claudeSessionId] ?? null;
|
|
356
424
|
}
|
|
357
425
|
}
|
|
426
|
+
// A conversation its agent no longer keeps: what was said can be read here, not gone on with.
|
|
427
|
+
const conversationGone = (agent) => new Conflict(m.goOn.conversationGone(agent), 'conversation-gone');
|
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { isDir } from '../../system/dirs.js';
|
|
2
|
-
import { Conflict } from '../../system/errors.js';
|
|
2
|
+
import { Conflict, NotFound } from '../../system/errors.js';
|
|
3
|
+
import { m } from '../../system/messages.js';
|
|
3
4
|
// A message Claude may not have taken in yet: waiting to go, being pasted, or held in Claude's own queue.
|
|
4
5
|
const NOT_TAKEN_IN = new Set(['queued', 'delivering', 'enqueued']);
|
|
6
|
+
// How often a session to move is looked at again while a Claude on the Mac holding its conversation is at work.
|
|
7
|
+
const MAC_LOOK_MS = 3000;
|
|
5
8
|
// Open Claude sessions moved to the account in use, each between its turns.
|
|
6
9
|
export class Moves {
|
|
7
10
|
s;
|
|
8
11
|
d;
|
|
9
12
|
p;
|
|
13
|
+
lookingAgain = new Set();
|
|
10
14
|
constructor(s, d, p) {
|
|
11
15
|
this.s = s;
|
|
12
16
|
this.d = d;
|
|
13
17
|
this.p = p;
|
|
14
18
|
}
|
|
15
|
-
//
|
|
16
|
-
|
|
19
|
+
// Its Claude idle, and all it was sent taken in.
|
|
20
|
+
betweenTurns(id) {
|
|
21
|
+
return this.s.statuses.get(id) === 'idle' && !this.d.journal.messages(id).some((q) => NOT_TAKEN_IN.has(q.state));
|
|
22
|
+
}
|
|
23
|
+
// A Claude session here whose Claude runs, in a folder still there to start it again in.
|
|
24
|
+
movable(id, meta) {
|
|
25
|
+
return meta.kind === 'claude' && !!meta.claudeSessionId && this.s.statuses.get(id) !== 'ended' && isDir(meta.cwd);
|
|
26
|
+
}
|
|
27
|
+
// One movable, started here rather than from a terminal, on another account than inUse.
|
|
17
28
|
onAnother(id, meta, inUse) {
|
|
18
|
-
return (
|
|
29
|
+
return this.movable(id, meta) && !meta.terminal && meta.claudeDir !== inUse;
|
|
19
30
|
}
|
|
20
31
|
// Those on another account, and of them those to move once their turn is over; none before an account is picked.
|
|
21
32
|
counts(inUse) {
|
|
@@ -45,9 +56,10 @@ export class Moves {
|
|
|
45
56
|
for (const id of ids)
|
|
46
57
|
void this.turnOver(id);
|
|
47
58
|
}
|
|
48
|
-
// Told a session is at its prompt: one to move moves now, unless what was sent to it may not be taken in yet
|
|
49
|
-
// stays marked until it has moved or failed to.
|
|
50
|
-
//
|
|
59
|
+
// Told a session is at its prompt: one to move moves now, unless what was sent to it may not be taken in yet or a
|
|
60
|
+
// Claude on the Mac holding its conversation is at work, and stays marked until it has moved or failed to. The
|
|
61
|
+
// idle Claudes there are quit first. One no longer on another account (its account picked again, or gone on with
|
|
62
|
+
// after its Claude quit) is not moved.
|
|
51
63
|
async turnOver(id) {
|
|
52
64
|
if (!this.d.store.get().sessions[id]?.moving)
|
|
53
65
|
return;
|
|
@@ -55,14 +67,54 @@ export class Moves {
|
|
|
55
67
|
const meta = this.d.store.get().sessions[id];
|
|
56
68
|
if (!meta?.moving)
|
|
57
69
|
return;
|
|
58
|
-
if (inUse === undefined || !this.
|
|
70
|
+
if (inUse === undefined || !this.movable(id, meta) || meta.claudeDir === inUse)
|
|
59
71
|
return this.unmark(id);
|
|
60
|
-
if (this.
|
|
72
|
+
if (!this.betweenTurns(id))
|
|
61
73
|
return;
|
|
62
|
-
|
|
63
|
-
.moveClaude(id)
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
try {
|
|
75
|
+
await this.p.lifecycle.moveClaude(id);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
if (e instanceof Conflict && e.code === 'open-on-mac-busy')
|
|
79
|
+
return this.lookAgain(id);
|
|
80
|
+
console.error(`workoffdesk: session ${id} could not be moved to the Claude account in use: ${e instanceof Error ? e.message : String(e)}`);
|
|
81
|
+
}
|
|
82
|
+
this.unmark(id);
|
|
83
|
+
}
|
|
84
|
+
// The one session asked for, one started from a terminal too, any Claude in a terminal on the Mac holding its
|
|
85
|
+
// conversation quit first. With no when, moved now if nothing is at work, else refused saying why; now, moved whatever
|
|
86
|
+
// is at work; free, marked to move once it is free (turnOver). Answered once it runs on the account in use, or marked.
|
|
87
|
+
async moveThis(id, when) {
|
|
88
|
+
const inUse = await this.p.accounts.accountInUse();
|
|
89
|
+
if (inUse === undefined)
|
|
90
|
+
throw new Conflict('no Claude account was picked yet', 'account-none-picked');
|
|
91
|
+
const meta = this.d.store.get().sessions[id];
|
|
92
|
+
if (!meta)
|
|
93
|
+
throw new NotFound('no such session');
|
|
94
|
+
if (!this.movable(id, meta))
|
|
95
|
+
throw new Conflict(m.moveThis.cannot, 'cannot-move');
|
|
96
|
+
if (meta.claudeDir === inUse)
|
|
97
|
+
return this.p.sessions.toSession(id, meta, Date.now());
|
|
98
|
+
if (when === 'free') {
|
|
99
|
+
this.d.store.update((st) => void (st.sessions[id] && (st.sessions[id].moving = true)));
|
|
100
|
+
this.s.emit();
|
|
101
|
+
void this.turnOver(id);
|
|
102
|
+
return this.p.sessions.toSession(id, this.d.store.get().sessions[id] ?? meta, Date.now());
|
|
103
|
+
}
|
|
104
|
+
if (when !== 'now' && !this.betweenTurns(id))
|
|
105
|
+
throw new Conflict(m.moveThis.busy, 'session-busy');
|
|
106
|
+
return this.p.lifecycle.moveClaude(id, when === 'now').catch((e) => {
|
|
107
|
+
throw e instanceof Conflict ? e : new Conflict(m.moveThis.failed(e instanceof Error ? e.message : String(e)), 'move-failed');
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
lookAgain(id) {
|
|
111
|
+
if (this.lookingAgain.has(id))
|
|
112
|
+
return;
|
|
113
|
+
this.lookingAgain.add(id);
|
|
114
|
+
setTimeout(() => {
|
|
115
|
+
this.lookingAgain.delete(id);
|
|
116
|
+
void this.turnOver(id);
|
|
117
|
+
}, MAC_LOOK_MS).unref();
|
|
66
118
|
}
|
|
67
119
|
unmark(id) {
|
|
68
120
|
this.d.store.update((st) => void (st.sessions[id] && delete st.sessions[id].moving));
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { tmuxName } from '../sessionName.js';
|
|
2
|
+
// How often the names on their way are looked at, and how long one typed waits for Claude to take it before it
|
|
3
|
+
// is typed again.
|
|
4
|
+
const CHECK_MS = 2_000;
|
|
5
|
+
const TYPED_AGAIN_MS = 30_000;
|
|
6
|
+
// A name given a session here, given to its agent too (SessionMeta.naming): Codex sets it on the thread; a
|
|
7
|
+
// Claude that runs is typed /rename when its prompt is free, and one that does not is started with --name.
|
|
8
|
+
export class Naming {
|
|
9
|
+
s;
|
|
10
|
+
d;
|
|
11
|
+
p;
|
|
12
|
+
typedAt = new Map();
|
|
13
|
+
run = null;
|
|
14
|
+
again = false;
|
|
15
|
+
cancel = null;
|
|
16
|
+
constructor(s, d, p) {
|
|
17
|
+
this.s = s;
|
|
18
|
+
this.d = d;
|
|
19
|
+
this.p = p;
|
|
20
|
+
}
|
|
21
|
+
// Gives each name on its way now, and again every CHECK_MS while any is.
|
|
22
|
+
watch() {
|
|
23
|
+
if (this.run) {
|
|
24
|
+
this.again = true;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.cancel?.();
|
|
28
|
+
this.cancel = null;
|
|
29
|
+
this.run = this.giveAll().finally(() => {
|
|
30
|
+
this.run = null;
|
|
31
|
+
if (this.again) {
|
|
32
|
+
this.again = false;
|
|
33
|
+
this.watch();
|
|
34
|
+
}
|
|
35
|
+
else if (this.onTheirWay().length)
|
|
36
|
+
this.cancel = this.s.later(CHECK_MS, () => this.watch());
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
// The agent took the name, or was started with it.
|
|
40
|
+
taken(id) {
|
|
41
|
+
this.typedAt.delete(id);
|
|
42
|
+
}
|
|
43
|
+
onTheirWay() {
|
|
44
|
+
return Object.entries(this.d.store.get().sessions).flatMap(([id, meta]) => (meta.naming ? [id] : []));
|
|
45
|
+
}
|
|
46
|
+
async giveAll() {
|
|
47
|
+
for (const id of this.onTheirWay())
|
|
48
|
+
await this.give(id).catch(() => { });
|
|
49
|
+
}
|
|
50
|
+
async give(id) {
|
|
51
|
+
const meta = this.d.store.get().sessions[id];
|
|
52
|
+
const name = meta?.naming;
|
|
53
|
+
if (!name)
|
|
54
|
+
return;
|
|
55
|
+
if (meta.kind === 'codex') {
|
|
56
|
+
if (!meta.codexThreadId || !this.d.codex)
|
|
57
|
+
return;
|
|
58
|
+
await this.d.codex.rename(meta.codexThreadId, name);
|
|
59
|
+
return this.clear(id, name);
|
|
60
|
+
}
|
|
61
|
+
if (meta.kind !== 'claude')
|
|
62
|
+
return this.clear(id, name);
|
|
63
|
+
const typed = this.typedAt.get(id);
|
|
64
|
+
if (typed !== undefined && Date.now() - typed < TYPED_AGAIN_MS)
|
|
65
|
+
return;
|
|
66
|
+
// One that ended is started again with --name.
|
|
67
|
+
if (this.s.statuses.get(id) === 'ended')
|
|
68
|
+
return;
|
|
69
|
+
// Typed over a dialog it would go into it; after half a message, it would send that.
|
|
70
|
+
if (!(await this.p.status.agentRuns(id)) || (await this.p.input.dialogOpen(id)))
|
|
71
|
+
return;
|
|
72
|
+
if (await this.d.claude.typed(tmuxName(id)))
|
|
73
|
+
return;
|
|
74
|
+
await this.d.tmux.leaveCopyMode(tmuxName(id));
|
|
75
|
+
await this.d.tmux.sendRaw(tmuxName(id), `/rename ${name}\r`);
|
|
76
|
+
this.typedAt.set(id, Date.now());
|
|
77
|
+
}
|
|
78
|
+
clear(id, name) {
|
|
79
|
+
this.d.store.update((st) => {
|
|
80
|
+
const now = st.sessions[id];
|
|
81
|
+
if (now?.naming === name)
|
|
82
|
+
delete now.naming;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -186,11 +186,10 @@ export class Status {
|
|
|
186
186
|
const stop = watchTranscript(this.d.claudeProjectsRoot, () => this.d.store.get().sessions[id]?.claudeSessionId, () => void this.p.sessions.ingest(id));
|
|
187
187
|
this.s.transcriptWatches.set(id, stop);
|
|
188
188
|
}
|
|
189
|
-
// A session gone from tmux
|
|
189
|
+
// A session gone from tmux: nothing of it runs to follow. Its rows stay while it is kept as past.
|
|
190
190
|
forget(id) {
|
|
191
191
|
this.s.transcriptWatches.get(id)?.();
|
|
192
192
|
this.s.transcriptWatches.delete(id);
|
|
193
|
-
this.d.journal.drop(id);
|
|
194
193
|
this.s.statuses.delete(id);
|
|
195
194
|
this.workSince.delete(id);
|
|
196
195
|
this.turnLooks.delete(id);
|