workoffdesk 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +21 -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/journal/db.js +4 -0
  35. package/dist/server/journal/index.js +4 -0
  36. package/dist/server/journal/ingest.js +26 -13
  37. package/dist/server/notify/apns.js +12 -7
  38. package/dist/server/notify/push.js +30 -4
  39. package/dist/server/notify/seal.js +12 -0
  40. package/dist/server/ops/doctor.js +7 -6
  41. package/dist/server/ops/problems.js +12 -7
  42. package/dist/server/sessions/control.js +65 -0
  43. package/dist/server/sessions/events.js +7 -5
  44. package/dist/server/sessions/machine/input.js +21 -2
  45. package/dist/server/sessions/machine/lifecycle.js +151 -81
  46. package/dist/server/sessions/machine/moves.js +65 -13
  47. package/dist/server/sessions/machine/naming.js +85 -0
  48. package/dist/server/sessions/machine/state.js +2 -0
  49. package/dist/server/sessions/machine/status.js +1 -2
  50. package/dist/server/sessions/machine/takeOvers.js +92 -0
  51. package/dist/server/sessions/machine.js +87 -23
  52. package/dist/server/sessions/outbox.js +7 -1
  53. package/dist/server/system/config.js +16 -1
  54. package/dist/server/system/errors.js +2 -2
  55. package/dist/server/system/messages.js +14 -176
  56. package/dist/server/system/messages.zh.js +186 -0
  57. package/dist/server/system/project.js +65 -0
  58. package/dist/server/system/store.js +1 -1
  59. package/dist/server/system/tmux.js +1 -1
  60. package/dist/web/assets/{Setup-C27bNf5I.js → Setup-OaIjxfwl.js} +1 -1
  61. package/dist/web/assets/main-DFndf3Z8.js +132 -0
  62. package/dist/web/assets/main-mChS_ic8.css +1 -0
  63. package/dist/web/icon-180.png +0 -0
  64. package/dist/web/icon-192.png +0 -0
  65. package/dist/web/icon-512.png +0 -0
  66. package/dist/web/icon-maskable-512.png +0 -0
  67. package/dist/web/icon.svg +14 -6
  68. package/dist/web/index.html +2 -2
  69. package/dist/web/manifest.webmanifest +1 -1
  70. package/dist/web/sw.js +4 -4
  71. package/package.json +3 -3
  72. package/dist/web/assets/main-C8GiLoJy.css +0 -1
  73. package/dist/web/assets/main-DQFM4yJU.js +0 -132
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ The release the iPhone app on the App Store needs: the app tells a Mac older than this to update.
6
+
7
+ - An iPhone app built for others to install takes its JS updates from one server named at build time, not
8
+ from the server it is signed in to. The iPhone app can be tried on a Mac it simulates, without a server.
9
+ - Notifications for an iPhone app published for others go through its publisher's relay, sealed on the Mac
10
+ for that one phone; the relay cannot read them. A server becomes a relay when `apns.json` names the app as
11
+ `relayTopic`.
12
+ - Every conversation on the Mac is listed, and ended sessions are kept to read. The list can be grouped by
13
+ project, and a session pinned to the top for every device.
14
+ - Swipe a row to end, delete or rename a session; a new name reaches Claude and Codex too.
15
+ - A session that hits its account's limit can move to another account, now or once free.
16
+ - The command Claude runs in the foreground can be sent to the background from the chat, and its background
17
+ agents are listed. A sub-agent's work can be followed from the call that started it.
18
+ - A message is held while something is typed at Claude's prompt on the Mac, and a message taken back from
19
+ Claude's queue on the Mac comes back here.
20
+ - Hold the empty box to talk; a picture pasted into the web composer is attached.
21
+ - The site can be reached at more than one address.
22
+ - The menu bar shows the app's mark, with a bar across it while sessions work.
23
+
3
24
  ## 1.0.1
4
25
 
5
26
  The first release published from GitHub Actions, with npm provenance.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # workoffdesk
1
+ # <img src="https://raw.githubusercontent.com/workoffdesk/workoffdesk/main/apps/web/public/icon.svg" alt="" width="40" align="top"> workoffdesk
2
2
 
3
3
  **Chat with the Claude Code and Codex sessions on your Mac from your phone.**
4
4
 
@@ -8,8 +8,9 @@ English · [简体中文](https://github.com/workoffdesk/workoffdesk/blob/main/R
8
8
 
9
9
  ## What makes it different
10
10
 
11
- - **No account, no relay of ours.** It is a small web app on your own Mac; your phone reaches it over the
12
- path you choose, such as Tailscale, a Cloudflare Tunnel or your own server.
11
+ - **No account, nothing in between.** It is a small web app on your own Mac; your phone reaches it over the
12
+ path you choose, such as Tailscale, a Cloudflare Tunnel or your own server. Only an iPhone app someone
13
+ publishes for others has its notifications passed on by the publisher's relay, sealed so it cannot read them.
13
14
  - **Sessions outlive the phone.** Each one runs in tmux on the Mac: closing the page, locking the phone or
14
15
  restarting the service does not end it.
15
16
  - **Leaving the desk, take it with you; back at it, keep going.** A Claude Code conversation running in a
@@ -148,6 +149,7 @@ Each is run as `workoffdesk <command>`.
148
149
  | `restart` | Restarts the service. Sessions keep running. |
149
150
  | `doctor` | Runs every check, prints the fix for each failure, and exits 1 while anything fails. |
150
151
  | `status --json` | The checks, the version, the releases, the address, whether the service runs, and its newest 20 warnings and errors. |
152
+ | `activity --json` | How many sessions are working: a line now and one at each change, until the service stops. The menu bar app reads it. |
151
153
  | `claude [--resume <session id>]` | Starts Claude in this folder as a session and joins it; with `--resume`, going on with that conversation. |
152
154
  | `codex [--resume <thread id>]` | The same for Codex. |
153
155
  | `attach [id]` | Joins a session from this terminal; without an id, lists them to pick one. |
package/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"version":"1.0.1","commit":"0c91daa8df475d40277aa688bddd0ed166ec32db","dirty":false}
1
+ {"version":"1.0.2","commit":"f5f5951ce2f9b2ccd30113b782cea86a923e3263","dirty":false}
@@ -0,0 +1,7 @@
1
+ // Whether Claude Code offers, under the tool it runs in the foreground, to send it to the background with its
2
+ // default key: a line of its own, the key named twice in tmux, whose prefix takes the first press. Another key
3
+ // bound to it is one not known here, so it is not offered.
4
+ const OFFER = /^\s*\(ctrl\+b(?: ctrl\+b \(twice\))? to run in background\)$/;
5
+ export function canBackground(screen) {
6
+ return screen.split('\n').some((l) => OFFER.test(l.trimEnd()));
7
+ }
@@ -21,3 +21,37 @@ const MODE = /(?:⏵⏵|⏸)\s+([a-z][a-z ]*?) on\b/;
21
21
  export function modeOn(screen) {
22
22
  return MODE.exec(screen)?.[1] ?? null;
23
23
  }
24
+ // What is typed at the prompt (❯ at the edge, right under the prompt's rule), or null when nothing is: text
25
+ // there that is all dimmed, if word by word, is Claude's suggestion, example, or hint for its queue.
26
+ // Dim (2) holds until 22 or a reset turns it off, through any other change of style.
27
+ // eslint-disable-next-line no-control-regex -- styles are escape sequences
28
+ const STYLE = /\x1b\[([0-9;]*)m/g;
29
+ function allDimmed(promptLine) {
30
+ const after = promptLine.slice(promptLine.indexOf('❯') + 1);
31
+ let dim = false;
32
+ let plain = '';
33
+ let from = 0;
34
+ for (const m of after.matchAll(STYLE)) {
35
+ if (!dim)
36
+ plain += after.slice(from, m.index);
37
+ for (const p of m[1].split(';')) {
38
+ if (p === '2')
39
+ dim = true;
40
+ else if (p === '' || p === '0' || p === '22')
41
+ dim = false;
42
+ }
43
+ from = m.index + m[0].length;
44
+ }
45
+ if (!dim)
46
+ plain += after.slice(from);
47
+ return !plain.replace(/\u00a0/g, ' ').trim();
48
+ }
49
+ export function typedOn(screen, styled) {
50
+ const lines = screen.replace(/\n+$/, '').split('\n');
51
+ const at = lines.findLastIndex((l, i) => l.startsWith('❯') && i > 0 && /^─{10,}/.test(lines[i - 1]));
52
+ const typed = at < 0 ? '' : lines[at].slice(1).replace(/\u00a0/g, ' ').trim();
53
+ if (!typed)
54
+ return null;
55
+ const promptLine = styled.split('\n').findLast((l) => l.includes('❯'));
56
+ return promptLine && allDimmed(promptLine) ? null : typed;
57
+ }
@@ -2,12 +2,19 @@ import { readFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { keysToChoose, keysToTick, parseChoices } from './choices.js';
5
+ import { canBackground } from './background.js';
5
6
  import { isCompacting } from './compacting.js';
6
7
  import { liveText } from './live.js';
7
8
  import { parsePanel } from './panel.js';
8
- import { modeOn, suggestionOn } from './prompt.js';
9
+ import { modeOn, suggestionOn, typedOn } from './prompt.js';
9
10
  // How far above the screen to look for the top edge of a dialog taller than it.
10
11
  const PANEL_ABOVE = 200;
12
+ // Ctrl+S, and how long what it puts aside is given to leave the prompt, looked at this often.
13
+ const STASH = '\x13';
14
+ const STASH_SHOWS_MS = 1000;
15
+ const STASH_LOOK_MS = 50;
16
+ // Ctrl+B, Claude Code's key to send the tool at work to the background.
17
+ const BACKGROUND = '\x02';
11
18
  // How much of a plan file is sent to be read on the phone.
12
19
  const PLAN_MAX = 20_000;
13
20
  // Claude Code's TUI in a tmux session, as read off its screen and answered by keys. Sessions are tmux
@@ -31,8 +38,30 @@ export class ClaudeScreen {
31
38
  ]);
32
39
  const choices = withPlan(parseChoices(screen));
33
40
  const panel = parsePanel(screen, above);
34
- const live = working && !choices && !panel ? liveText(joined, cols) : null;
35
- return { screen: { choices, panel, suggestion: suggestionOn(styled), mode: modeOn(screen), live }, compacting: isCompacting(screen) };
41
+ const free = working && !choices && !panel;
42
+ const live = free ? liveText(joined, cols) : null;
43
+ const background = free && canBackground(joined);
44
+ return { screen: { choices, panel, suggestion: suggestionOn(styled), mode: modeOn(screen), live, background }, compacting: isCompacting(screen) };
45
+ }
46
+ // What is typed at the prompt, or null when nothing is.
47
+ async typed(name) {
48
+ const [screen, styled] = await Promise.all([this.tmux.screen(name), this.tmux.screenStyled(name).catch(() => '')]);
49
+ return typedOn(screen, styled);
50
+ }
51
+ // Puts what is typed at the prompt aside with Claude Code's stash (ctrl+s), which it puts back once the
52
+ // next prompt is sent, so what is sent next goes in on its own. Pressed only over typed text: over an empty
53
+ // prompt the key brings back what was put aside before. False when it did not go aside.
54
+ async clearPrompt(name) {
55
+ if ((await this.typed(name)) === null)
56
+ return true;
57
+ await this.tmux.leaveCopyMode(name);
58
+ await this.tmux.sendRaw(name, STASH);
59
+ for (let waited = 0; waited < STASH_SHOWS_MS; waited += STASH_LOOK_MS) {
60
+ await new Promise((r) => setTimeout(r, STASH_LOOK_MS));
61
+ if ((await this.typed(name)) === null)
62
+ return true;
63
+ }
64
+ return false;
36
65
  }
37
66
  async choices(name, parse = parseChoices) {
38
67
  return parse(await this.tmux.screen(name));
@@ -52,6 +81,16 @@ export class ClaudeScreen {
52
81
  await this.tmux.paste(name, text);
53
82
  return true;
54
83
  }
84
+ // Sends the tool Claude runs in the foreground to the background with Claude Code's key, if the screen still
85
+ // offers it; false when it does not (the tool is over, or a dialog has the keyboard). The key goes to the
86
+ // pane itself, past tmux's prefix, so it is pressed once.
87
+ async toBackground(name) {
88
+ if (!(await this.read(name, true)).screen.background)
89
+ return false;
90
+ await this.tmux.leaveCopyMode(name);
91
+ await this.tmux.sendRaw(name, BACKGROUND);
92
+ return true;
93
+ }
55
94
  // Sends a question that takes several answers with the options at `ticked` ticked, if it is still the
56
95
  // one whose options were `labels`.
57
96
  async tick(name, ticked, labels) {
@@ -1,32 +1,87 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { open } from 'node:fs/promises';
3
- export const busiest = (claudes) => claudes.some((c) => c.status === 'waiting') ? 'waiting' : claudes.some((c) => c.status === 'busy') ? 'busy' : 'idle';
3
+ import { turnInTranscript } from './transcript/turn.js';
4
+ const busiest = (claudes) => claudes.some((c) => c.status === 'waiting') ? 'waiting' : claudes.some((c) => c.status === 'busy') ? 'busy' : 'idle';
5
+ // What a conversation open in these terminals is doing, its transcript read only when Claude Code calls it busy.
6
+ export async function openStatus(claudes, transcript) {
7
+ const status = busiest(claudes);
8
+ if (status !== 'busy')
9
+ return status;
10
+ const path = await transcript();
11
+ return path && turnInTranscript(path)?.over ? 'background' : status;
12
+ }
4
13
  const LIST_MS = 5_000;
5
14
  // How long a Claude told to stop is given to write the rest of its transcript and go: it took under a second.
6
15
  const STOP_MS = 10_000;
7
16
  const STOP_LOOK_MS = 100;
8
17
  const STATUSES = new Set(['idle', 'busy', 'waiting']);
9
- const run = (bin, args, env) => new Promise((resolve, reject) => execFile(bin, args, { timeout: LIST_MS, maxBuffer: 16 * 1024 * 1024, env }, (err, stdout) => (err ? reject(err) : resolve(String(stdout)))));
18
+ // Claude Code, or ps, did not answer in time: what is open on the Mac cannot be told, which is not that none is.
19
+ export class MacUnanswered extends Error {
20
+ constructor() {
21
+ super('the Mac did not say in time which Claude conversations are open in its terminals');
22
+ }
23
+ }
24
+ const run = (bin, args, env) => new Promise((resolve, reject) => execFile(bin, args, { timeout: LIST_MS, maxBuffer: 16 * 1024 * 1024, env }, (err, stdout) => err ? reject(err.killed ? new MacUnanswered() : err) : resolve(String(stdout))));
25
+ // What a program that answered, but not with a listing, is taken for; one that did not answer stays unanswered.
26
+ const orElse = (value) => (e) => {
27
+ if (e instanceof MacUnanswered)
28
+ throw e;
29
+ return value(e);
30
+ };
31
+ const outOf = (e) => String(e.stdout ?? '');
10
32
  // Asked for again while a listing runs, the one running answers.
11
33
  const running = new Map();
34
+ const made = new Map();
12
35
  // None when Claude Code cannot say (too old for `agents`, not installed): then nothing is known to be open.
13
- export function listMacClaudes(claudeBin) {
14
- const now = running.get(claudeBin);
15
- if (now)
16
- return now;
36
+ // MacUnanswered when it does not answer in time. recentMs: for what is only shown, one made up to that long ago
37
+ // will do, and is answered at once while a new one is made (listing starts Claude Code, which can take seconds);
38
+ // one not answered then is the last that was.
39
+ export function listMacClaudes(claudeBin, recentMs = 0) {
40
+ return listed(claudeBin, recentMs).then((all) => all.flatMap(({ name: _, here, ...c }) => (here ? [] : [c])));
41
+ }
42
+ // The conversation and name of a Claude running on this Mac, WorkOffDesk's own included, by its process.
43
+ export async function findMacClaude(claudeBin, pid) {
44
+ const c = (await listed(claudeBin, 0)).find((a) => a.pid === pid);
45
+ return c ? { sessionId: c.sessionId, name: c.name } : null;
46
+ }
47
+ function listed(claudeBin, recentMs) {
48
+ const last = made.get(claudeBin);
49
+ const listing = running.get(claudeBin) ?? listAgain(claudeBin);
50
+ if (!recentMs)
51
+ return listing;
52
+ if (last && Date.now() - last.at < recentMs)
53
+ return Promise.resolve(last.claudes);
54
+ return listing.catch(() => made.get(claudeBin)?.claudes ?? []);
55
+ }
56
+ function listAgain(claudeBin) {
57
+ const at = Date.now();
17
58
  const next = listNow(claudeBin)
18
- .catch(() => [])
59
+ .catch(orElse(() => []))
60
+ .then((claudes) => {
61
+ made.set(claudeBin, { at, claudes });
62
+ return claudes;
63
+ })
19
64
  .finally(() => running.delete(claudeBin));
20
65
  running.set(claudeBin, next);
66
+ // Those answered from the last listing do not wait on this one.
67
+ next.catch(() => { });
21
68
  return next;
22
69
  }
23
70
  async function listNow(claudeBin) {
24
71
  const listings = await Promise.all([...(await configDirs())].map((dir) => run(claudeBin, ['agents', '--json'], dir === undefined ? undefined : { ...process.env, CLAUDE_CONFIG_DIR: dir })
25
72
  .then((out) => JSON.parse(out))
26
- .catch(() => [])));
73
+ .catch(orElse(() => []))));
27
74
  const listed = listings.flatMap((l) => (Array.isArray(l) ? l : []));
28
75
  const found = listed.flatMap((a) => a && a.kind === 'interactive' && Number.isSafeInteger(a.pid) && typeof a.sessionId === 'string' && typeof a.cwd === 'string'
29
- ? [{ pid: a.pid, sessionId: a.sessionId, cwd: a.cwd, status: STATUSES.has(a.status) ? a.status : 'busy' }]
76
+ ? [
77
+ {
78
+ pid: a.pid,
79
+ sessionId: a.sessionId,
80
+ cwd: a.cwd,
81
+ status: STATUSES.has(a.status) ? a.status : 'busy',
82
+ name: typeof a.name === 'string' && a.name ? a.name : null,
83
+ },
84
+ ]
30
85
  : []);
31
86
  // One dir can be named two ways (this server's own, and set by a terminal to the same path).
32
87
  const interactive = [...new Map(found.map((a) => [a.pid, a])).values()];
@@ -35,14 +90,14 @@ async function listNow(claudeBin) {
35
90
  const procs = await processes(interactive.map((a) => a.pid));
36
91
  return interactive.flatMap((a) => {
37
92
  const p = procs.get(a.pid);
38
- return p && !p.inWorkOffDesk ? [{ ...a, tty: p.tty }] : [];
93
+ return p ? [{ ...a, tty: p.tty, here: p.inWorkOffDesk }] : [];
39
94
  });
40
95
  }
41
96
  // Where Claude Code keeps who is running: each Claude lists itself in its own config dir, and a terminal can
42
97
  // set CLAUDE_CONFIG_DIR while sharing the conversations with the rest. undefined: this server's own.
43
98
  async function configDirs() {
44
99
  const dirs = new Set([undefined]);
45
- const out = await run('/bin/ps', ['-E', '-ww', '-A', '-o', 'command=']).catch((e) => String(e.stdout ?? ''));
100
+ const out = await run('/bin/ps', ['-E', '-ww', '-A', '-o', 'command=']).catch(orElse(outOf));
46
101
  for (const line of out.split('\n')) {
47
102
  // Claude Code's own program, or the one npm installs, which node runs.
48
103
  if (!/^(\S*\/)?(node\s+(\S*\/)?)?claude(\s|$)/.test(line))
@@ -57,7 +112,7 @@ async function configDirs() {
57
112
  // with WORKOFFDESK_SESSION, whichever WorkOffDesk on this Mac started it, and what it starts inherits it.
58
113
  // One gone since it was listed is left out.
59
114
  async function processes(pids) {
60
- const out = await run('/bin/ps', ['-E', '-ww', '-o', 'pid=,tty=,command=', '-p', pids.join(',')]).catch((e) => String(e.stdout ?? ''));
115
+ const out = await run('/bin/ps', ['-E', '-ww', '-o', 'pid=,tty=,command=', '-p', pids.join(',')]).catch(orElse(outOf));
61
116
  const procs = new Map();
62
117
  for (const line of out.split('\n')) {
63
118
  const [, pid, tty, command] = /^\s*(\d+)\s+(\S+)\s(.*)$/.exec(line) ?? [];
@@ -1,25 +1,17 @@
1
- import { stripVTControlCharacters } from 'node:util';
2
1
  import { isOwnHookCommand } from '../hooks/hooks.js';
2
+ import { eventOf, tagText } from './events.js';
3
+ import { unhandled } from '../../events.js';
3
4
  const SUMMARY_MAX = 200;
4
5
  const DIFF_LINES_MAX = 300;
5
6
  const DIFF_LINE_MAX = 400;
6
7
  const RESULT_MAX = 2000;
8
+ const INPUT_MAX = 4000;
7
9
  // What Claude Code records as the answer of a call it was stopped before running.
8
10
  const STOPPED = /^The user doesn't want to (proceed with this tool use|take this action)/;
9
11
  const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}…` : s);
10
12
  const str = (v) => (typeof v === 'string' ? v : '');
11
- // As text, without the colours and links a terminal would have drawn from it.
12
- function blocksText(content) {
13
- if (typeof content === 'string')
14
- return stripVTControlCharacters(content);
15
- if (!Array.isArray(content))
16
- return '';
17
- return content
18
- .map((b) => (b?.type === 'text' ? stripVTControlCharacters(str(b.text)) : b?.type === 'image' ? '[image]' : ''))
19
- .filter(Boolean)
20
- .join(' ');
21
- }
22
- export function toolSummary(name, input) {
13
+ // The text a call's summary is of, whole.
14
+ function toolSaid(name, input) {
23
15
  const pick = (...keys) => keys.map((k) => str(input[k])).find(Boolean);
24
16
  const named = {
25
17
  Bash: () => pick('description', 'command'),
@@ -34,8 +26,15 @@ export function toolSummary(name, input) {
34
26
  Agent: () => pick('description', 'prompt'),
35
27
  Task: () => pick('description', 'prompt'),
36
28
  }[name];
37
- const s = named?.() ?? Object.values(input).map(str).find(Boolean) ?? '';
38
- return clip(s.split('\n')[0], SUMMARY_MAX);
29
+ return named?.() ?? Object.values(input).map(str).find(Boolean) ?? '';
30
+ }
31
+ export const toolSummary = (name, input) => clip(toolSaid(name, input).split('\n')[0], SUMMARY_MAX);
32
+ // Where what a call was asked is not the text its summary is of.
33
+ const WHOLE = { Bash: 'command', Agent: 'prompt', Task: 'prompt' };
34
+ // What a call was asked to do, whole, when its summary says less.
35
+ function toolInput(name, input, summary) {
36
+ const whole = str(input[WHOLE[name]]) || toolSaid(name, input);
37
+ return whole && whole !== summary ? clip(whole, INPUT_MAX) : undefined;
39
38
  }
40
39
  // What Claude Code prints after a command for each hook that ran, when the hook is workoffdesk's own.
41
40
  const ownHookLine = (line) => {
@@ -50,9 +49,11 @@ function taskMade(v) {
50
49
  const t = task;
51
50
  return typeof t.id === 'string' && typeof t.subject === 'string' ? { k: 'task', id: t.id, subject: t.subject, status: 'pending' } : null;
52
51
  }
53
- // A command Claude ran in the background, or one the user sent there while it ran.
52
+ // A command Claude ran in the background, or one the user sent there while it ran, or an agent it started
53
+ // there; each one's end comes as a task notification with this id.
54
54
  function backgroundStarted(v, tool) {
55
- const id = v && typeof v === 'object' ? v.backgroundTaskId : null;
55
+ const r = v && typeof v === 'object' ? v : {};
56
+ const id = r.status === 'async_launched' ? r.agentId : r.backgroundTaskId;
56
57
  return typeof id === 'string' ? [{ k: 'bg', id, tool, status: 'running' }] : [];
57
58
  }
58
59
  // A change to the task list, from what Claude asked for.
@@ -102,44 +103,20 @@ function fileChange(v) {
102
103
  .filter((h) => h.lines.length);
103
104
  return { file: t.filePath, added, removed, ...(created ? { created: true } : {}), ...(all.length > DIFF_LINES_MAX ? { cut: true } : {}), hunks: kept };
104
105
  }
105
- function userItems(r, at) {
106
- const content = r.message?.content;
107
- if (r.isCompactSummary)
108
- return [{ k: 'note', event: { e: 'compacted' } }];
109
- if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result')) {
110
- const results = content.filter((b) => b?.type === 'tool_result' && typeof b.tool_use_id === 'string');
111
- const diff = results.length === 1 && results[0].is_error !== true ? fileChange(r.toolUseResult) : null;
112
- const made = results.length === 1 && results[0].is_error !== true ? taskMade(r.toolUseResult) : null;
113
- const shown = results.map((b) => {
114
- const text = blocksText(b.content);
115
- if (STOPPED.test(text))
116
- return { k: 'result', id: b.tool_use_id, text: '', error: false, stopped: true };
117
- const item = { k: 'result', id: b.tool_use_id, text: clip(text, RESULT_MAX), error: b.is_error === true };
118
- if (diff)
119
- item.diff = diff;
120
- return Array.isArray(b.content) && b.content.some((c) => c?.type === 'image') ? { ...item, image: true } : item;
121
- });
122
- const started = results.length === 1 ? backgroundStarted(r.toolUseResult, results[0].tool_use_id) : [];
123
- return [...shown, ...(made ? [made] : []), ...started];
124
- }
125
- const text = blocksText(content).trim();
126
- if (!text)
127
- return [];
128
- if (text.startsWith('[Request interrupted'))
129
- return [{ k: 'note', event: { e: 'interrupted' }, retry: true }];
130
- const command = /<command-name>([^<]*)<\/command-name>/.exec(text);
131
- if (command?.[1].trim() === '/compact')
132
- return [];
133
- if (command) {
134
- const args = /<command-args>([^<]*)<\/command-args>/.exec(text)?.[1].trim();
135
- return [{ k: 'note', text: [command[1].trim(), args].filter(Boolean).join(' '), command: true, at }];
136
- }
137
- const stdout = /^<local-command-std(?:out|err)>([\s\S]*)<\/local-command-std(?:out|err)>$/.exec(text);
138
- if (stdout) {
139
- const shown = stdout[1].split('\n').filter((l) => !ownHookLine(l)).join('\n').trim();
140
- return shown ? [{ k: 'note', text: clip(shown, RESULT_MAX) }] : [];
141
- }
142
- return [{ k: 'user', text, at }];
106
+ function resultItems(results, detail) {
107
+ const alone = results.length === 1 && !results[0].error;
108
+ const diff = alone ? fileChange(detail) : null;
109
+ const made = alone ? taskMade(detail) : null;
110
+ const shown = results.map((b) => {
111
+ if (STOPPED.test(b.text))
112
+ return { k: 'result', id: b.id, text: '', error: false, stopped: true };
113
+ const item = { k: 'result', id: b.id, text: clip(b.text, RESULT_MAX), error: b.error };
114
+ if (diff)
115
+ item.diff = diff;
116
+ return b.image ? { ...item, image: true } : item;
117
+ });
118
+ const started = results.length === 1 ? backgroundStarted(detail, results[0].id) : [];
119
+ return [...shown, ...(made ? [made] : []), ...started];
143
120
  }
144
121
  // The words a message sent to Claude has when the transcript records it as this item: a prompt's, or a
145
122
  // command's typed at the prompt. Null for anything a message cannot be.
@@ -148,8 +125,6 @@ export function sentWords(item) {
148
125
  return item.text;
149
126
  return item.k === 'note' && 'command' in item && item.command ? item.text : null;
150
127
  }
151
- const XML_ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&apos;': "'" };
152
- const tagText = (text, tag) => new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text)?.[1].replace(/&(?:amp|lt|gt|quot|apos);/g, (e) => XML_ENTITIES[e]).trim() ?? '';
153
128
  function taskNote(text) {
154
129
  const summary = tagText(text, 'summary');
155
130
  const what = /^Background command "([\s\S]*)" (?:completed|failed|was stopped)/.exec(summary)?.[1] ?? summary;
@@ -158,34 +133,6 @@ function taskNote(text) {
158
133
  const note = { k: 'note', event: { e: 'task', status, what }, detail: summary };
159
134
  return id && status ? [note, { k: 'bg', id, status }] : [note];
160
135
  }
161
- // Teammates' messages, which Claude Code records with no sender: it wraps them in this envelope instead.
162
- // A body that is a JSON object is a status update between agents (idle, shutting down), for Claude alone.
163
- const TEAMMATES = 'Another Claude session sent a message:\n<teammate-message ';
164
- function fromTeammates(text) {
165
- if (!text.startsWith(TEAMMATES))
166
- return null;
167
- return [...text.matchAll(/<teammate-message\s[^>]*?teammate_id="([^"]*)"[^>]*>([\s\S]*?)<\/teammate-message>/g)].flatMap(([, from, body]) => {
168
- const detail = body.trim();
169
- return detail && !/^\{[\s\S]*\}$/.test(detail) ? [{ k: 'note', event: { e: 'peer', from }, detail }] : [];
170
- });
171
- }
172
- // A prompt that did not come from the user: Claude Code records who sent it. Older versions record
173
- // nobody, and then the prompt is the user's, unless it is a teammate's message in its envelope.
174
- function sentByOther(r) {
175
- const origin = r.origin && typeof r.origin === 'object' ? r.origin : null;
176
- if (!origin)
177
- return fromTeammates(blocksText(r.message?.content).trim());
178
- if (origin.kind === 'human')
179
- return null;
180
- if (origin.kind === 'peer')
181
- return [{ k: 'note', event: { e: 'peer', from: str(origin.name) }, detail: str(origin.body) }];
182
- if (r.isMeta)
183
- return [];
184
- const text = blocksText(r.message?.content).trim();
185
- if (origin.kind === 'task-notification')
186
- return taskNote(text);
187
- return text ? [{ k: 'note', text: clip(text, RESULT_MAX) }] : [];
188
- }
189
136
  // The plan Claude asks to go ahead with, shown whole rather than as a line: written into Claude Code's
190
137
  // plans folder in plan mode, or given to ExitPlanMode itself by older versions.
191
138
  const PLAN_FILE = /\/\.claude\/plans\/[^/]+\.md$/;
@@ -196,59 +143,75 @@ function planOf(name, input) {
196
143
  return str(input.content).trim();
197
144
  return '';
198
145
  }
199
- function assistantItems(r, at) {
200
- const content = r.message?.content;
201
- if (!Array.isArray(content))
202
- return [];
203
- const out = [];
204
- for (const b of content) {
205
- if (b?.type === 'text' && str(b.text).trim())
206
- out.push({ k: 'text', text: b.text.trim(), at });
207
- if (b?.type === 'tool_use' && typeof b.id === 'string') {
208
- const name = str(b.name) || 'Tool';
209
- const input = b.input && typeof b.input === 'object' ? b.input : {};
210
- const plan = planOf(name, input);
211
- out.push({ k: 'tool', id: b.id, name, summary: toolSummary(name, input), at, ...(plan && { plan }) }, ...taskItems(name, input));
212
- }
213
- }
214
- return out;
215
- }
216
- // A message the user sent while Claude worked, which Claude took in the middle of its turn: the transcript
217
- // has it only as this attachment.
218
- function takenMidTurn(r, at) {
219
- const a = r.attachment && typeof r.attachment === 'object' ? r.attachment : null;
220
- if (a?.type !== 'queued_command' || a.isMeta)
221
- return [];
222
- const origin = a.origin && typeof a.origin === 'object' ? a.origin : null;
223
- // How a background command ended, taken in mid-turn: only the end is kept, the note comes as its own prompt.
224
- if (origin?.kind === 'task-notification' || a.commandMode === 'task-notification')
225
- return taskNote(blocksText(a.prompt)).filter((i) => i.k === 'bg');
226
- if ((a.commandMode ?? 'prompt') !== 'prompt' || (origin && origin.kind !== 'human'))
227
- return [];
228
- const text = blocksText(a.prompt).trim();
229
- return text ? [{ k: 'user', text, at }] : [];
146
+ function replyItems(blocks, at) {
147
+ return blocks.flatMap((b) => {
148
+ if (b.type === 'text')
149
+ return b.text.trim() ? [{ k: 'text', text: b.text.trim(), at }] : [];
150
+ const name = b.name || 'Tool';
151
+ const plan = planOf(name, b.input);
152
+ const summary = toolSummary(name, b.input);
153
+ const input = toolInput(name, b.input, summary);
154
+ return [{ k: 'tool', id: b.id, name, summary, ...(input && { input }), at, ...(plan && { plan }) }, ...taskItems(name, b.input)];
155
+ });
230
156
  }
231
157
  // What one transcript record shows in the conversation. Subagents' own turns, Claude's thinking and
232
158
  // text injected for Claude alone are not part of it.
233
159
  export function chatItems(v) {
234
- if (!v || typeof v !== 'object')
235
- return [];
236
- const r = v;
237
- if (r.isSidechain)
238
- return [];
239
- const at = typeof r.timestamp === 'string' ? r.timestamp : undefined;
240
- if (r.type === 'attachment')
241
- return takenMidTurn(r, at);
242
- if (!r.message || typeof r.message !== 'object')
243
- return [];
244
- if (r.type === 'user')
245
- return sentByOther(r) ?? (r.isMeta ? [] : userItems(r, at));
246
- // A reply the API failed to give: what Claude Code wrote in its place says why.
247
- if (r.type === 'assistant' && r.isApiErrorMessage) {
248
- const event = r.error === 'rate_limit' ? { e: 'failed', limit: true } : { e: 'failed' };
249
- return [{ k: 'note', event, detail: clip(blocksText(r.message.content).replace(/^API Error:\s*/, ''), RESULT_MAX), retry: true }];
160
+ return eventItems(eventOf(v));
161
+ }
162
+ function eventItems(e) {
163
+ const at = e.at;
164
+ switch (e.k) {
165
+ case 'claude:prompt':
166
+ return e.text ? [{ k: 'user', text: e.text, at }] : [];
167
+ // A message sent while Claude worked, which it took in the middle of its turn.
168
+ case 'claude:midTurnPrompt':
169
+ return e.text ? [{ k: 'user', text: e.text, at }] : [];
170
+ // How a background command ended, taken in mid-turn: only the end is kept, the note comes as its own prompt.
171
+ case 'claude:midTurnTaskEnd':
172
+ return taskNote(e.text).filter((i) => i.k === 'bg');
173
+ case 'claude:command':
174
+ return e.local || e.name === '/compact' ? [] : [{ k: 'note', text: [e.name, e.args].filter(Boolean).join(' '), command: true, at }];
175
+ case 'claude:commandOutput': {
176
+ if (e.local)
177
+ return [];
178
+ const shown = e.text.split('\n').filter((l) => !ownHookLine(l)).join('\n').trim();
179
+ return shown ? [{ k: 'note', text: clip(shown, RESULT_MAX) }] : [];
180
+ }
181
+ case 'claude:shell':
182
+ return [{ k: 'note', text: `!${e.command}`, command: true, at }];
183
+ case 'claude:shellOutput':
184
+ return e.text ? [{ k: 'note', text: clip(e.text, RESULT_MAX) }] : [];
185
+ case 'claude:results':
186
+ return resultItems(e.results, e.detail);
187
+ case 'claude:interrupted':
188
+ return [{ k: 'note', event: { e: 'interrupted' }, retry: true }];
189
+ case 'claude:compacted':
190
+ return [{ k: 'note', event: { e: 'compacted' } }];
191
+ case 'claude:peer':
192
+ return [{ k: 'note', event: { e: 'peer', from: e.from, ...(e.pid !== undefined && { pid: e.pid }) }, detail: e.body }];
193
+ // A body that is a JSON object is a status update between agents (idle, shutting down), for Claude alone.
194
+ case 'claude:teammates':
195
+ return e.messages.flatMap((m) => (m.body && !/^\{[\s\S]*\}$/.test(m.body) ? [{ k: 'note', event: { e: 'peer', from: m.from }, detail: m.body }] : []));
196
+ case 'claude:taskNotification':
197
+ return taskNote(e.text);
198
+ case 'claude:fromOther':
199
+ return e.text ? [{ k: 'note', text: clip(e.text, RESULT_MAX) }] : [];
200
+ case 'claude:reply':
201
+ return replyItems(e.blocks, at);
202
+ // A reply the API failed to give: what Claude Code wrote in its place says why.
203
+ case 'claude:apiError':
204
+ return [{ k: 'note', event: e.limit ? { e: 'failed', limit: true } : { e: 'failed' }, detail: clip(e.text.replace(/^API Error:\s*/, ''), RESULT_MAX), retry: true }];
205
+ case 'claude:noted':
206
+ case 'claude:compactBoundary':
207
+ case 'claude:turnOver':
208
+ case 'claude:queue':
209
+ case 'claude:title':
210
+ case 'claude:lastPrompt':
211
+ case 'claude:sidechain':
212
+ case 'claude:other':
213
+ return [];
214
+ default:
215
+ return unhandled(e);
250
216
  }
251
- if (r.type === 'assistant')
252
- return assistantItems(r, at);
253
- return [];
254
217
  }