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.
Files changed (74) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +5 -3
  3. package/dist/build.json +1 -1
  4. package/dist/server/agents/claude/screen/background.js +7 -0
  5. package/dist/server/agents/claude/screen/prompt.js +34 -0
  6. package/dist/server/agents/claude/screen/screen.js +42 -3
  7. package/dist/server/agents/claude/terminals.js +67 -12
  8. package/dist/server/agents/claude/transcript/chat.js +98 -135
  9. package/dist/server/agents/claude/transcript/events.js +158 -0
  10. package/dist/server/agents/claude/transcript/transcriptFile.js +50 -10
  11. package/dist/server/agents/claude/transcript/transcripts.js +118 -30
  12. package/dist/server/agents/claude/transcript/turn.js +40 -61
  13. package/dist/server/agents/codex/agent.js +9 -2
  14. package/dist/server/agents/codex/events.js +107 -0
  15. package/dist/server/agents/codex/items.js +54 -74
  16. package/dist/server/agents/codex/notifications.js +14 -13
  17. package/dist/server/agents/codex/threads.js +54 -40
  18. package/dist/server/agents/events.js +4 -0
  19. package/dist/server/agents/turnSays.js +58 -0
  20. package/dist/server/cli.js +2 -0
  21. package/dist/server/commands/activity.js +28 -0
  22. package/dist/server/http/app.js +8 -4
  23. package/dist/server/http/auth/guard.js +8 -4
  24. package/dist/server/http/auth/passkey.js +6 -3
  25. package/dist/server/http/routes/appUpdate.js +1 -1
  26. package/dist/server/http/routes/chat.js +10 -0
  27. package/dist/server/http/routes/claude.js +70 -16
  28. package/dist/server/http/routes/codex.js +38 -3
  29. package/dist/server/http/routes/journal.js +3 -1
  30. package/dist/server/http/routes/push.js +36 -5
  31. package/dist/server/http/routes/pushRelay.js +62 -0
  32. package/dist/server/http/routes/sessions.js +32 -1
  33. package/dist/server/http/serve.js +8 -2
  34. package/dist/server/http/sessionSocket.js +12 -11
  35. package/dist/server/journal/db.js +4 -0
  36. package/dist/server/journal/index.js +4 -0
  37. package/dist/server/journal/ingest.js +26 -13
  38. package/dist/server/notify/apns.js +12 -7
  39. package/dist/server/notify/push.js +30 -4
  40. package/dist/server/notify/seal.js +12 -0
  41. package/dist/server/ops/doctor.js +7 -6
  42. package/dist/server/ops/problems.js +12 -7
  43. package/dist/server/sessions/control.js +65 -0
  44. package/dist/server/sessions/events.js +7 -5
  45. package/dist/server/sessions/machine/input.js +21 -2
  46. package/dist/server/sessions/machine/lifecycle.js +151 -81
  47. package/dist/server/sessions/machine/moves.js +65 -13
  48. package/dist/server/sessions/machine/naming.js +85 -0
  49. package/dist/server/sessions/machine/state.js +2 -0
  50. package/dist/server/sessions/machine/status.js +1 -2
  51. package/dist/server/sessions/machine/takeOvers.js +92 -0
  52. package/dist/server/sessions/machine.js +87 -23
  53. package/dist/server/sessions/outbox.js +7 -1
  54. package/dist/server/system/config.js +16 -1
  55. package/dist/server/system/errors.js +2 -2
  56. package/dist/server/system/messages.js +14 -176
  57. package/dist/server/system/messages.zh.js +186 -0
  58. package/dist/server/system/project.js +65 -0
  59. package/dist/server/system/store.js +1 -1
  60. package/dist/server/system/tmux.js +1 -1
  61. package/dist/web/assets/{Setup-DinzDcF1.js → Setup-OaIjxfwl.js} +1 -1
  62. package/dist/web/assets/main-DFndf3Z8.js +132 -0
  63. package/dist/web/assets/main-mChS_ic8.css +1 -0
  64. package/dist/web/icon-180.png +0 -0
  65. package/dist/web/icon-192.png +0 -0
  66. package/dist/web/icon-512.png +0 -0
  67. package/dist/web/icon-maskable-512.png +0 -0
  68. package/dist/web/icon.svg +14 -6
  69. package/dist/web/index.html +2 -2
  70. package/dist/web/manifest.webmanifest +1 -1
  71. package/dist/web/sw.js +4 -4
  72. package/package.json +3 -3
  73. package/dist/web/assets/main-C8GiLoJy.css +0 -1
  74. 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 { busiest, listMacClaudes, stopMacProcess } from '../../agents/claude/terminals.js';
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 meta = this.d.store.get().sessions[id];
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.cancelTakeOver(spec.resumeId);
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. One whose Claude does not
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 pane = await this.d.tmux.panePid(tmuxName(id)).catch(() => undefined);
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 new Error(`its Claude did not quit within ${MOVE_QUIT_MS / 1000} seconds, and it stays on its account`);
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.cancelTakeOver(spec.resumeId);
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 holder = (await this.codexWhere(spec.resumeId))?.holder;
135
- const id = newId();
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
- return this.startSession({ kind: 'codex', cwd: spec.cwd, resumeId: spec.resumeId, title: spec.title }, id);
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
- // What a conversation is doing in a terminal on the Mac: undefined when it is open in none.
152
- async onMacStatus(spec) {
153
- if (spec.kind === 'claude')
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
- for (const c of await listMacClaudes(this.d.claudeBin))
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
- return new Map([...open].map(([sid, list]) => [sid, { claudes: list, status: busiest(list) }]));
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
- async takeOverWhenIdle(spec) {
170
- const sid = spec.resumeId;
171
- if (this.takeOvers.has(sid))
172
- return null;
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(claudeSessionId) {
204
- return this.takeOvers.has(claudeSessionId) || this.s.resuming.has(claudeSessionId);
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 claude = await this.claudeCommand(said ? { resume: spec.resumeId } : { sessionId: spec.resumeId }, spec.env);
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 = sessionTitle(title, meta.cwd);
320
- now.named = !!nameLine(title);
378
+ now.title = name;
379
+ now.named = true;
380
+ now.naming = name;
321
381
  if (now.claudeSessionId)
322
- nameConversation(st, now.claudeSessionId, nameLine(title));
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 custom = nameLine(found.custom) || undefined;
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
- // A Claude session here whose Claude runs, started here rather than from a terminal, on another account than inUse,
16
- // in a folder still there to start it again in.
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 (meta.kind === 'claude' && !!meta.claudeSessionId && !meta.terminal && this.s.statuses.get(id) !== 'ended' && meta.claudeDir !== inUse && isDir(meta.cwd));
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, and
49
- // stays marked until it has moved or failed to. One no longer on another account (its account picked again, or
50
- // gone on with after its Claude quit) is not moved.
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.onAnother(id, meta, inUse))
70
+ if (inUse === undefined || !this.movable(id, meta) || meta.claudeDir === inUse)
59
71
  return this.unmark(id);
60
- if (this.s.statuses.get(id) !== 'idle' || this.d.journal.messages(id).some((q) => NOT_TAKEN_IN.has(q.state)))
72
+ if (!this.betweenTurns(id))
61
73
  return;
62
- await this.p.lifecycle
63
- .moveClaude(id)
64
- .catch((e) => console.error(`workoffdesk: session ${id} could not be moved to the Claude account in use: ${e instanceof Error ? e.message : String(e)}`))
65
- .finally(() => this.unmark(id));
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
+ }
@@ -69,3 +69,5 @@ export function codexOf(d) {
69
69
  throw new InvalidRequest('Codex is not set up on this Mac');
70
70
  return d.codex;
71
71
  }
72
+ // The conversation a session holds, Claude's or Codex's.
73
+ export const conversationOf = (s) => s.claudeSessionId ?? s.codexThreadId;
@@ -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 is gone for good, and so are its rows; a device keeps its own copy.
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);