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.
- package/CHANGELOG.md +21 -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/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-C27bNf5I.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-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,
|
|
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
|
+
{"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
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
? [
|
|
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
|
|
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((
|
|
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((
|
|
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
|
-
//
|
|
12
|
-
function
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'" };
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
}
|