workoffdesk 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Whether Claude Code is compacting its conversation, read off the screen: its spinner, the line nearest above
|
|
2
|
+
// the prompt, says so (a tip may sit under it). The conversation is further up and indented, so words in it
|
|
3
|
+
// are not taken for the spinner.
|
|
4
|
+
const PROMPT_TOP = /^─{10,}/;
|
|
5
|
+
const PROMPT_LINE = /^[❯!]/;
|
|
6
|
+
const TIP = /^\s*⎿/;
|
|
7
|
+
// Any glyph but the ⏺ that starts each of Claude's replies, then what the spinner says.
|
|
8
|
+
const SPINNER = /^[^\s⏺]\s+(Compacting conversation|Running (Pre|Post)Compact hooks)/;
|
|
9
|
+
export function isCompacting(screen) {
|
|
10
|
+
const lines = screen.split('\n').map((l) => l.trimEnd());
|
|
11
|
+
let top = -1;
|
|
12
|
+
for (let i = lines.length - 2; i >= 0 && top < 0; i--)
|
|
13
|
+
if (PROMPT_TOP.test(lines[i]) && PROMPT_LINE.test(lines[i + 1]))
|
|
14
|
+
top = i;
|
|
15
|
+
const above = lines.slice(0, Math.max(top, 0)).filter((l) => l.trim());
|
|
16
|
+
let spinner = above.pop();
|
|
17
|
+
if (spinner !== undefined && TIP.test(spinner))
|
|
18
|
+
spinner = above.pop();
|
|
19
|
+
return spinner !== undefined && SPINNER.test(spinner);
|
|
20
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// What Claude is writing now, read off the screen: the transcript gets a reply only once it is whole, while
|
|
2
|
+
// the screen shows it line by line. Its text is the last block that starts with ⏺ and goes on in lines
|
|
3
|
+
// indented two spaces, above the rule over the prompt and below the last message the user sent, so the reply
|
|
4
|
+
// to an earlier message is never taken for this one's. A block that is a tool at work, or reads and searches,
|
|
5
|
+
// is not a reply.
|
|
6
|
+
const BLOCK_START = /^⏺ /;
|
|
7
|
+
const GOES_ON = /^ {2}/;
|
|
8
|
+
const TOOL = /^⏺ [\w.:-]+\(/;
|
|
9
|
+
// Reads and searches, drawn as one line that opens with ctrl+o; while they run its ⏺ blinks to a space.
|
|
10
|
+
const CALLS = /^(?:⏺| ) \S.*\(ctrl\+o to expand\)$/;
|
|
11
|
+
const TOOL_RESULT = '⎿';
|
|
12
|
+
// A message the user sent, and the prompt they type at, which sits between two rules.
|
|
13
|
+
const SENT = /^❯(?:[\u00a0 ]|$)/;
|
|
14
|
+
const RULE = /^─{8,}$/;
|
|
15
|
+
const INDENT = 2;
|
|
16
|
+
// Lines that start a block of their own (list item, heading, quote, fence, table) are never a wrapped tail.
|
|
17
|
+
const OWN_LINE = /^(?:[-*+] |\d+[.)] |#|>|```|\|)/;
|
|
18
|
+
const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
|
|
19
|
+
function cells(s) {
|
|
20
|
+
let n = 0;
|
|
21
|
+
for (const ch of s)
|
|
22
|
+
n += WIDE.test(ch) ? 2 : 1;
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
25
|
+
// Claude Code breaks its own lines to fit the terminal, which -J cannot undo. A line was broken by the
|
|
26
|
+
// terminal, not by Claude, when the next line's first word would not have fitted after it.
|
|
27
|
+
function unwrap(lines, width) {
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
const prev = out[out.length - 1];
|
|
31
|
+
const first = /^\S+/.exec(line)?.[0] ?? '';
|
|
32
|
+
const wide = WIDE.test(first[0] ?? '');
|
|
33
|
+
const head = wide ? first[0] : first;
|
|
34
|
+
const fits = prev !== undefined && cells(prev) + (wide ? 0 : 1) + cells(head) <= width;
|
|
35
|
+
if (prev && line && !OWN_LINE.test(line) && !fits) {
|
|
36
|
+
out[out.length - 1] = prev + (wide || WIDE.test(prev.slice(-1)) ? '' : ' ') + line;
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
out.push(line);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// `screen` is a capture with wrapped lines joined (capture-pane -J); `cols` is the width it was drawn at.
|
|
44
|
+
export function liveText(screen, cols) {
|
|
45
|
+
const lines = screen.split('\n').map((l) => l.trimEnd());
|
|
46
|
+
let prompt = lines.length;
|
|
47
|
+
for (let i = lines.length - 1; i > 0 && prompt === lines.length; i--)
|
|
48
|
+
if (SENT.test(lines[i]) && RULE.test(lines[i - 1]))
|
|
49
|
+
prompt = i - 1;
|
|
50
|
+
let start = -1;
|
|
51
|
+
for (let i = prompt - 1; i >= 0 && start < 0 && !SENT.test(lines[i]); i--)
|
|
52
|
+
if (BLOCK_START.test(lines[i]) || CALLS.test(lines[i]))
|
|
53
|
+
start = i;
|
|
54
|
+
if (start < 0 || TOOL.test(lines[start]) || CALLS.test(lines[start]))
|
|
55
|
+
return null;
|
|
56
|
+
const block = [lines[start].slice(INDENT)];
|
|
57
|
+
for (let i = start + 1; i < lines.length && (!lines[i] || GOES_ON.test(lines[i])); i++)
|
|
58
|
+
block.push(lines[i].slice(INDENT));
|
|
59
|
+
if (block.some((l) => l.includes(TOOL_RESULT)))
|
|
60
|
+
return null;
|
|
61
|
+
const text = unwrap(block, cols - INDENT).join('\n').trim();
|
|
62
|
+
return text || null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// A dialog a slash command opened over Claude Code's prompt (/usage, /config, /model, …), read off the
|
|
2
|
+
// screen. While one is up the prompt is gone, so typing goes into the dialog instead of to Claude.
|
|
3
|
+
// Its top edge is a row of ▔ (other text may share that row) or a plain rule; taller than the screen, that
|
|
4
|
+
// edge has scrolled into `above`, the scrollback just above the screen. Its key hint is the last block.
|
|
5
|
+
import { parseChoices } from './choices.js';
|
|
6
|
+
const RULE = /^─{10,}$/;
|
|
7
|
+
// The prompt: a rule, maybe with a label on it, over the line being typed (❯, or ! in shell mode).
|
|
8
|
+
const PROMPT_TOP = /^─{10,}/;
|
|
9
|
+
const PROMPT_LINE = /^[❯!]/;
|
|
10
|
+
const EDGE = '▔';
|
|
11
|
+
// The settings dialog /config, /usage and /status open, whichever tab it is on.
|
|
12
|
+
const SETTINGS_TABS = /^Settings\s{2,}Status\s{2,}Config\b/;
|
|
13
|
+
const TABS_WRAPPED = /^\s*(Status|Config|Usage|Stats)(\s{2,}(Status|Config|Usage|Stats))*\s*$/;
|
|
14
|
+
const SEARCH_BOX = /^[╭│╰]/;
|
|
15
|
+
const HINT = /\bEsc to \w+|\bEnter to \w+/;
|
|
16
|
+
const TITLE_MAX = 40;
|
|
17
|
+
// The dialogs known by name. The settings dialog says which tab is up only by what it holds.
|
|
18
|
+
const HEADINGS = { 'Select model': 'Model', Effort: 'Effort' };
|
|
19
|
+
const USAGE_TAB = /^\s*Total cost:/m;
|
|
20
|
+
const CONFIG_TAB = /to search\b|Esc to clear/;
|
|
21
|
+
export function parsePanel(screen, above = '') {
|
|
22
|
+
let shown = screen.split('\n').map((l) => l.trimEnd());
|
|
23
|
+
// A dialog opened while Claude works stands above the prompt rather than in its place: what is above
|
|
24
|
+
// the prompt is a dialog only when its key hint is the last thing there.
|
|
25
|
+
const prompt = shown.findIndex((l, i) => i > 0 && PROMPT_TOP.test(shown[i - 1].trim()) && PROMPT_LINE.test(l));
|
|
26
|
+
if (prompt > 0) {
|
|
27
|
+
shown = shown.slice(0, prompt - 1);
|
|
28
|
+
while (shown.length && !shown[shown.length - 1])
|
|
29
|
+
shown.pop();
|
|
30
|
+
if (!HINT.test(shown.slice(shown.lastIndexOf('') + 1).join(' ')))
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const btw = btwOf(shown);
|
|
34
|
+
if (btw)
|
|
35
|
+
return btw;
|
|
36
|
+
// A question Claude asks is for the choices; a dialog with options in it, whose edge is ▔, is not.
|
|
37
|
+
if (!shown.some((l) => l.endsWith(EDGE)) && parseChoices(screen))
|
|
38
|
+
return null;
|
|
39
|
+
const lines = above ? [...above.split('\n').map((l) => l.trimEnd()), ...shown] : shown;
|
|
40
|
+
let top = -1;
|
|
41
|
+
for (let i = lines.length - 1; i >= 0 && top < 0; i--)
|
|
42
|
+
if (lines[i].endsWith(EDGE) || RULE.test(lines[i].trim()))
|
|
43
|
+
top = i;
|
|
44
|
+
if (top < 0)
|
|
45
|
+
return null;
|
|
46
|
+
let rest = lines.slice(top + 1);
|
|
47
|
+
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length));
|
|
48
|
+
rest = rest.map((l) => l.slice(indent)).filter((l) => !SEARCH_BOX.test(l));
|
|
49
|
+
// The key hint: the last block of lines, wrapped across them.
|
|
50
|
+
while (rest.length && !rest[rest.length - 1])
|
|
51
|
+
rest.pop();
|
|
52
|
+
let hint = '';
|
|
53
|
+
const blank = rest.lastIndexOf('');
|
|
54
|
+
const last = rest.slice(blank + 1).join(' ');
|
|
55
|
+
if (HINT.test(last)) {
|
|
56
|
+
hint = last.replace(/\s+/g, ' ').trim();
|
|
57
|
+
rest = rest.slice(0, Math.max(blank, 0));
|
|
58
|
+
}
|
|
59
|
+
const first = rest.findIndex((l) => l.trim());
|
|
60
|
+
if (first < 0)
|
|
61
|
+
return null;
|
|
62
|
+
const heading = rest[first].trim();
|
|
63
|
+
let from = first + 1;
|
|
64
|
+
// The settings tabs wrapped onto a second line on a narrow screen.
|
|
65
|
+
if (SETTINGS_TABS.test(heading) && TABS_WRAPPED.test(rest[from] ?? ''))
|
|
66
|
+
from++;
|
|
67
|
+
const body = rest
|
|
68
|
+
.slice(from)
|
|
69
|
+
.join('\n')
|
|
70
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
71
|
+
.trim();
|
|
72
|
+
if (SETTINGS_TABS.test(heading))
|
|
73
|
+
return usageOr({ title: CONFIG_TAB.test(hint) ? 'Settings' : '', body, hint });
|
|
74
|
+
const title = heading ? (HEADINGS[heading] ?? heading.slice(0, TITLE_MAX)) : '';
|
|
75
|
+
const effort = heading === 'Effort' ? effortOf(rest.slice(from)) : null;
|
|
76
|
+
return effort ? { title, body, hint, effort } : usageOr({ title, body, hint });
|
|
77
|
+
}
|
|
78
|
+
// A /btw answer has no edge: the questions asked so far, each a line of `/btw …`, then the answer to the
|
|
79
|
+
// last, then its key hint, which offers to fork it.
|
|
80
|
+
const BTW_ASKED = /^\s*\/btw\s/;
|
|
81
|
+
const BTW_HINT = /\bto fork\b.*\bEsc to close\b/;
|
|
82
|
+
function btwOf(shown) {
|
|
83
|
+
const lines = [...shown];
|
|
84
|
+
while (lines.length && !lines[lines.length - 1])
|
|
85
|
+
lines.pop();
|
|
86
|
+
const gap = lines.lastIndexOf('');
|
|
87
|
+
const hint = lines
|
|
88
|
+
.slice(gap + 1)
|
|
89
|
+
.join(' ')
|
|
90
|
+
.replace(/\s+/g, ' ')
|
|
91
|
+
.trim();
|
|
92
|
+
if (gap < 0 || !BTW_HINT.test(hint))
|
|
93
|
+
return null;
|
|
94
|
+
const asked = lines.slice(0, gap).findLastIndex((l) => BTW_ASKED.test(l));
|
|
95
|
+
if (asked < 0)
|
|
96
|
+
return null;
|
|
97
|
+
// The question runs on until the blank line under it, when it is too long for one.
|
|
98
|
+
const below = lines.indexOf('', asked);
|
|
99
|
+
const question = lines
|
|
100
|
+
.slice(asked, below)
|
|
101
|
+
.join(' ')
|
|
102
|
+
.replace(/\s+/g, ' ')
|
|
103
|
+
.trim()
|
|
104
|
+
.replace(/^\/btw\s+/, '');
|
|
105
|
+
const answer = lines.slice(below + 1, gap);
|
|
106
|
+
const indent = Math.min(...answer.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length));
|
|
107
|
+
const body = answer
|
|
108
|
+
.map((l) => l.slice(Number.isFinite(indent) ? indent : 0))
|
|
109
|
+
.join('\n')
|
|
110
|
+
.trim();
|
|
111
|
+
return { title: 'Btw', body, hint, btw: { question } };
|
|
112
|
+
}
|
|
113
|
+
// The slider's marker (▲) on one line, the levels named under it; the level is the one whose name is
|
|
114
|
+
// centred nearest the marker.
|
|
115
|
+
const MARKER = '▲';
|
|
116
|
+
const LEVELS_END = /\s{3,}Tab to toggle.*$/;
|
|
117
|
+
function effortOf(lines) {
|
|
118
|
+
const marked = lines.findIndex((l) => l.includes(MARKER));
|
|
119
|
+
const named = lines[marked + 1];
|
|
120
|
+
if (marked < 0 || !named)
|
|
121
|
+
return null;
|
|
122
|
+
const mark = lines[marked].indexOf(MARKER);
|
|
123
|
+
const words = [...named.replace(LEVELS_END, '').matchAll(/\S+/g)];
|
|
124
|
+
if (words.length < 2)
|
|
125
|
+
return null;
|
|
126
|
+
const off = (w) => Math.abs(w.index + w[0].length / 2 - mark);
|
|
127
|
+
const at = words.reduce((best, w, i) => (off(w) < off(words[best]) ? i : best), 0);
|
|
128
|
+
return { levels: words.map((w) => w[0]), at };
|
|
129
|
+
}
|
|
130
|
+
function usageOr(p) {
|
|
131
|
+
const { body, hint } = p;
|
|
132
|
+
return USAGE_TAB.test(body) ? { title: 'Usage', body, hint, usage: { session: used(body, 'Current session'), week: used(body, 'Current week') } } : p;
|
|
133
|
+
}
|
|
134
|
+
// The first percentage below a line of the plan. The session's line comes above the week's, so when its
|
|
135
|
+
// own percentage is off the screen, so is everything after it.
|
|
136
|
+
function used(body, line) {
|
|
137
|
+
const at = body.indexOf(line);
|
|
138
|
+
const m = at < 0 ? null : /(\d+)% used/.exec(body.slice(at));
|
|
139
|
+
return m ? Number(m[1]) : null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What Claude Code shows at its prompt besides what is typed there.
|
|
2
|
+
// After Claude answers it may offer what the user is likely to say next, dimmed at the empty prompt; what
|
|
3
|
+
// is typed there is not dimmed.
|
|
4
|
+
// eslint-disable-next-line no-control-regex -- the dimming is an escape sequence
|
|
5
|
+
const SUGGESTION = new RegExp('❯[\\u00a0 ]\\x1b\\[2m([^\\x1b\\n]+)\\x1b\\[0m\\s*$');
|
|
6
|
+
export function suggestionOn(styled) {
|
|
7
|
+
const lines = styled.replace(/\n+$/, '').split('\n');
|
|
8
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
9
|
+
if (!lines[i].includes('❯'))
|
|
10
|
+
continue;
|
|
11
|
+
const m = SUGGESTION.exec(lines[i]);
|
|
12
|
+
const text = m?.[1].trim();
|
|
13
|
+
// The hint on a fresh prompt is an example, not something to send.
|
|
14
|
+
return text && !/^Try "/.test(text) ? text : null;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
// The permission mode, from the line under the prompt: "bypass permissions", "accept edits", "plan mode",
|
|
19
|
+
// "auto mode"; null in the default mode, which shows none.
|
|
20
|
+
const MODE = /(?:⏵⏵|⏸)\s+([a-z][a-z ]*?) on\b/;
|
|
21
|
+
export function modeOn(screen) {
|
|
22
|
+
return MODE.exec(screen)?.[1] ?? null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { keysToChoose, keysToTick, parseChoices } from './choices.js';
|
|
5
|
+
import { isCompacting } from './compacting.js';
|
|
6
|
+
import { liveText } from './live.js';
|
|
7
|
+
import { parsePanel } from './panel.js';
|
|
8
|
+
import { modeOn, suggestionOn } from './prompt.js';
|
|
9
|
+
// How far above the screen to look for the top edge of a dialog taller than it.
|
|
10
|
+
const PANEL_ABOVE = 200;
|
|
11
|
+
// How much of a plan file is sent to be read on the phone.
|
|
12
|
+
const PLAN_MAX = 20_000;
|
|
13
|
+
// Claude Code's TUI in a tmux session, as read off its screen and answered by keys. Sessions are tmux
|
|
14
|
+
// session names.
|
|
15
|
+
export class ClaudeScreen {
|
|
16
|
+
tmux;
|
|
17
|
+
cols;
|
|
18
|
+
constructor(tmux,
|
|
19
|
+
// The width Claude's lines are taken to wrap at when the window's own cannot be read.
|
|
20
|
+
cols) {
|
|
21
|
+
this.tmux = tmux;
|
|
22
|
+
this.cols = cols;
|
|
23
|
+
}
|
|
24
|
+
// working: Claude is at work, so the text it is writing is read too. compacting: its spinner shows.
|
|
25
|
+
async read(name, working) {
|
|
26
|
+
const [{ screen, above }, styled, joined, cols] = await Promise.all([
|
|
27
|
+
this.tmux.screenAndAbove(name, PANEL_ABOVE),
|
|
28
|
+
this.tmux.screenStyled(name).catch(() => ''),
|
|
29
|
+
working ? this.tmux.screenJoined(name).catch(() => '') : Promise.resolve(''),
|
|
30
|
+
working ? this.tmux.windowSize(name).then((s) => s.cols, () => this.cols) : Promise.resolve(this.cols),
|
|
31
|
+
]);
|
|
32
|
+
const choices = withPlan(parseChoices(screen));
|
|
33
|
+
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) };
|
|
36
|
+
}
|
|
37
|
+
async choices(name, parse = parseChoices) {
|
|
38
|
+
return parse(await this.tmux.screen(name));
|
|
39
|
+
}
|
|
40
|
+
// Takes option `index` if it is still `label` on screen; false when the screen has moved on. parse: how
|
|
41
|
+
// the screen's choices are read, for a TUI other than Claude Code's drawn the same way. text: an answer of
|
|
42
|
+
// one's own, for the option that is a field for one, typed into it and taken.
|
|
43
|
+
async choose(name, index, label, parse = parseChoices, text) {
|
|
44
|
+
const c = await this.choices(name, parse);
|
|
45
|
+
if (!c || c.options[index]?.label !== label)
|
|
46
|
+
return false;
|
|
47
|
+
if (text !== undefined && !c.options[index].typed)
|
|
48
|
+
return false;
|
|
49
|
+
await this.tmux.leaveCopyMode(name);
|
|
50
|
+
await this.tmux.sendRaw(name, keysToChoose(c, index));
|
|
51
|
+
if (text !== undefined)
|
|
52
|
+
await this.tmux.paste(name, text);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
// Sends a question that takes several answers with the options at `ticked` ticked, if it is still the
|
|
56
|
+
// one whose options were `labels`.
|
|
57
|
+
async tick(name, ticked, labels) {
|
|
58
|
+
const c = await this.choices(name);
|
|
59
|
+
if (!c?.submit || c.options.length !== labels.length || c.options.some((o, i) => o.label !== labels[i]))
|
|
60
|
+
return false;
|
|
61
|
+
if (ticked.some((i) => i >= c.options.length || c.options[i].typed))
|
|
62
|
+
return false;
|
|
63
|
+
await this.tmux.leaveCopyMode(name);
|
|
64
|
+
await this.tmux.sendRaw(name, keysToTick(c, ticked));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// A choice to go ahead with a plan, with the plan it names read from Claude Code's plans folder; the name
|
|
69
|
+
// is a bare file name, so nothing outside that folder is read.
|
|
70
|
+
function withPlan(c) {
|
|
71
|
+
if (!c?.planFile)
|
|
72
|
+
return c;
|
|
73
|
+
try {
|
|
74
|
+
return { ...c, plan: readFileSync(join(homedir(), '.claude', 'plans', basename(c.planFile)), 'utf8').slice(0, PLAN_MAX) };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return c;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
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';
|
|
4
|
+
const LIST_MS = 5_000;
|
|
5
|
+
// How long a Claude told to stop is given to write the rest of its transcript and go: it took under a second.
|
|
6
|
+
const STOP_MS = 10_000;
|
|
7
|
+
const STOP_LOOK_MS = 100;
|
|
8
|
+
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)))));
|
|
10
|
+
// Asked for again while a listing runs, the one running answers.
|
|
11
|
+
const running = new Map();
|
|
12
|
+
// 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;
|
|
17
|
+
const next = listNow(claudeBin)
|
|
18
|
+
.catch(() => [])
|
|
19
|
+
.finally(() => running.delete(claudeBin));
|
|
20
|
+
running.set(claudeBin, next);
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
async function listNow(claudeBin) {
|
|
24
|
+
const listings = await Promise.all([...(await configDirs())].map((dir) => run(claudeBin, ['agents', '--json'], dir === undefined ? undefined : { ...process.env, CLAUDE_CONFIG_DIR: dir })
|
|
25
|
+
.then((out) => JSON.parse(out))
|
|
26
|
+
.catch(() => [])));
|
|
27
|
+
const listed = listings.flatMap((l) => (Array.isArray(l) ? l : []));
|
|
28
|
+
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' }]
|
|
30
|
+
: []);
|
|
31
|
+
// One dir can be named two ways (this server's own, and set by a terminal to the same path).
|
|
32
|
+
const interactive = [...new Map(found.map((a) => [a.pid, a])).values()];
|
|
33
|
+
if (!interactive.length)
|
|
34
|
+
return [];
|
|
35
|
+
const procs = await processes(interactive.map((a) => a.pid));
|
|
36
|
+
return interactive.flatMap((a) => {
|
|
37
|
+
const p = procs.get(a.pid);
|
|
38
|
+
return p && !p.inWorkOffDesk ? [{ ...a, tty: p.tty }] : [];
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// Where Claude Code keeps who is running: each Claude lists itself in its own config dir, and a terminal can
|
|
42
|
+
// set CLAUDE_CONFIG_DIR while sharing the conversations with the rest. undefined: this server's own.
|
|
43
|
+
async function configDirs() {
|
|
44
|
+
const dirs = new Set([undefined]);
|
|
45
|
+
const out = await run('/bin/ps', ['-E', '-ww', '-A', '-o', 'command=']).catch((e) => String(e.stdout ?? ''));
|
|
46
|
+
for (const line of out.split('\n')) {
|
|
47
|
+
// Claude Code's own program, or the one npm installs, which node runs.
|
|
48
|
+
if (!/^(\S*\/)?(node\s+(\S*\/)?)?claude(\s|$)/.test(line))
|
|
49
|
+
continue;
|
|
50
|
+
const dir = /(?:^|\s)CLAUDE_CONFIG_DIR=(\S+)/.exec(line)?.[1];
|
|
51
|
+
if (dir)
|
|
52
|
+
dirs.add(dir);
|
|
53
|
+
}
|
|
54
|
+
return dirs;
|
|
55
|
+
}
|
|
56
|
+
// Each process's terminal, and whether it runs in a WorkOffDesk session: every session's program is started
|
|
57
|
+
// with WORKOFFDESK_SESSION, whichever WorkOffDesk on this Mac started it, and what it starts inherits it.
|
|
58
|
+
// One gone since it was listed is left out.
|
|
59
|
+
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 ?? ''));
|
|
61
|
+
const procs = new Map();
|
|
62
|
+
for (const line of out.split('\n')) {
|
|
63
|
+
const [, pid, tty, command] = /^\s*(\d+)\s+(\S+)\s(.*)$/.exec(line) ?? [];
|
|
64
|
+
if (!pid)
|
|
65
|
+
continue;
|
|
66
|
+
procs.set(Number(pid), { tty: /^ttys?\d+$/.test(tty) ? tty : null, inWorkOffDesk: /(^|\s)WORKOFFDESK_SESSION=\S/.test(command) });
|
|
67
|
+
}
|
|
68
|
+
return procs;
|
|
69
|
+
}
|
|
70
|
+
const alive = (pid) => {
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return e.code === 'EPERM';
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
// Tells an agent in a terminal (Claude, Codex) to quit as its own Ctrl-C would, and waits for it to be gone:
|
|
80
|
+
// what it writes is whole then. What it was writing in a turn is lost; what the person sent stays. note: said
|
|
81
|
+
// in the terminal it leaves, under the agent's own resume line, which would open the conversation a second time.
|
|
82
|
+
// False when it would not go in time; it is not killed harder, and must not be resumed while it runs.
|
|
83
|
+
export async function stopMacProcess(c, note) {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(c.pid, 'SIGTERM');
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e.code !== 'ESRCH')
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
const until = Date.now() + STOP_MS;
|
|
92
|
+
while (alive(c.pid)) {
|
|
93
|
+
if (Date.now() > until)
|
|
94
|
+
return false;
|
|
95
|
+
await new Promise((r) => setTimeout(r, STOP_LOOK_MS));
|
|
96
|
+
}
|
|
97
|
+
if (c.tty)
|
|
98
|
+
await say(`/dev/${c.tty}`, note);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
// The terminal may be closed with it (a tab that ran claude alone); then there is no one to tell.
|
|
102
|
+
async function say(tty, note) {
|
|
103
|
+
try {
|
|
104
|
+
const fh = await open(tty, 'w');
|
|
105
|
+
await fh.write(`\r\n${note.replace(/\n/g, '\r\n')}\r\n`).finally(() => fh.close());
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// gone with it
|
|
109
|
+
}
|
|
110
|
+
}
|