workoffdesk 0.0.0-stage → 1.0.1

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 (128) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +199 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-C27bNf5I.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-DQFM4yJU.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
@@ -0,0 +1,114 @@
1
+ import { readFileSync, rmSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { writeJsonAtomic } from '../../../system/paths.js';
4
+ import { HOOK_EVENTS } from './status.js';
5
+ const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
6
+ const hookCommand = (sockPath) => `curl -s -o /dev/null --max-time 2 --unix-socket ${shq(sockPath)} ` +
7
+ `-H "x-workoffdesk-session: $WORKOFFDESK_SESSION" --data-binary @- http://localhost/hook || true`;
8
+ // How long a permission prompt's hook waits for the phone. The dialog is on the Mac's screen meanwhile.
9
+ const PERMISSION_HOLD_S = 3600;
10
+ // Prints what the server answers, which is the hook's decision; nothing when it cannot be reached.
11
+ const permissionCommand = (sockPath) => `curl -s --max-time ${PERMISSION_HOLD_S} --unix-socket ${shq(sockPath)} ` +
12
+ `-H "x-workoffdesk-session: $WORKOFFDESK_SESSION" --data-binary @- http://localhost/permission || true`;
13
+ const QUOTED = /^'(?:[^']|'\\'')*'$/;
14
+ const OWN = [hookCommand, permissionCommand].map((make) => make('\0').split(shq('\0')));
15
+ // Whether a hook command is one workoffdesk installs, for a service home anywhere.
16
+ export function isOwnHookCommand(command) {
17
+ return OWN.some(([head, tail]) => command.startsWith(head) && command.endsWith(tail) && QUOTED.test(command.slice(head.length, command.length - tail.length)));
18
+ }
19
+ const statusPost = (sockPath) => `curl -s -o /dev/null --max-time 2 --unix-socket ${shq(sockPath)} ` +
20
+ `-H "x-workoffdesk-session: $WORKOFFDESK_SESSION" --data-binary @- http://localhost/status`;
21
+ // The status line the user set for Claude Code themselves, which keeps drawing the terminal's.
22
+ export function userStatusLine(settingsPath) {
23
+ try {
24
+ const s = JSON.parse(readFileSync(settingsPath, 'utf8'));
25
+ const l = s.statusLine;
26
+ if (l?.type !== 'command' || typeof l.command !== 'string' || !l.command)
27
+ return undefined;
28
+ return typeof l.padding === 'number' ? { type: 'command', command: l.command, padding: l.padding } : { type: 'command', command: l.command };
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ // What the status line is given goes to workoffdesk too, and the user's own status line still draws it.
35
+ export function statusLineCommand(sockPath, own) {
36
+ const post = statusPost(sockPath);
37
+ if (!own)
38
+ return `${post} >/dev/null 2>&1 || true`;
39
+ return `input=$(cat); { printf '%s' "$input" | ${post} >/dev/null 2>&1 & } ; printf '%s' "$input" | { ${own}
40
+ }`;
41
+ }
42
+ export function writeClaudeSettings(path, sockPath, own) {
43
+ const command = hookCommand(sockPath);
44
+ const hooks = {
45
+ ...Object.fromEntries(HOOK_EVENTS.map((event) => [event, [{ hooks: [{ type: 'command', command, timeout: 5 }] }]])),
46
+ PermissionRequest: [{ hooks: [{ type: 'command', command: permissionCommand(sockPath), timeout: PERMISSION_HOLD_S }] }],
47
+ };
48
+ const statusLine = { type: 'command', command: statusLineCommand(sockPath, own?.command) };
49
+ if (own?.padding !== undefined)
50
+ statusLine.padding = own.padding;
51
+ // Agent view moves a conversation out of its window, on quitting or by /background, into a session the
52
+ // phone cannot follow; what is typed afterwards starts new ones.
53
+ writeJsonAtomic(path, { hooks, statusLine, disableAgentView: true });
54
+ }
55
+ const MAX_BODY = 1 << 20;
56
+ export async function startHookServer(sockPath, onEvent, onStatus = () => { },
57
+ // A permission prompt, held open until `hook` is answered.
58
+ onPermission = (_id, _p, hook) => hook.reply(null)) {
59
+ const server = createServer((req, res) => {
60
+ const termId = req.headers['x-workoffdesk-session'];
61
+ let size = 0;
62
+ const chunks = [];
63
+ req.on('data', (c) => {
64
+ size += c.length;
65
+ if (size > MAX_BODY)
66
+ req.destroy();
67
+ else
68
+ chunks.push(c);
69
+ });
70
+ req.on('end', () => {
71
+ const held = req.url === '/permission';
72
+ if (!held)
73
+ res.end();
74
+ let payload;
75
+ try {
76
+ payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
77
+ }
78
+ catch {
79
+ payload = null;
80
+ }
81
+ if (typeof termId !== 'string' || !termId || !payload || typeof payload !== 'object') {
82
+ if (held)
83
+ res.end();
84
+ return;
85
+ }
86
+ if (held) {
87
+ onPermission(termId, payload, {
88
+ reply: (output) => res.writableEnded || res.end(output ? JSON.stringify(output) : ''),
89
+ onGone: (cb) => res.on('close', () => res.writableEnded || cb()),
90
+ });
91
+ }
92
+ else if (req.url === '/status')
93
+ onStatus(termId, payload);
94
+ else
95
+ onEvent(termId, payload);
96
+ });
97
+ });
98
+ return listenOwnerOnly(server, sockPath);
99
+ }
100
+ // The umask makes the socket owner-only from the moment it exists.
101
+ export async function listenOwnerOnly(server, sockPath) {
102
+ rmSync(sockPath, { force: true });
103
+ const oldMask = process.umask(0o177);
104
+ try {
105
+ await new Promise((resolve, reject) => {
106
+ server.once('error', reject);
107
+ server.listen(sockPath, () => resolve());
108
+ });
109
+ }
110
+ finally {
111
+ process.umask(oldMask);
112
+ }
113
+ return server;
114
+ }
@@ -0,0 +1,34 @@
1
+ const TRANSITIONS = {
2
+ UserPromptSubmit: 'working',
3
+ PostToolUse: 'working',
4
+ PostToolUseFailure: 'working',
5
+ Notification: 'waiting',
6
+ Stop: 'idle',
7
+ // A turn ended by an API error, a rate limit or an overload.
8
+ StopFailure: 'idle',
9
+ };
10
+ // SessionStart says which transcript Claude writes from now on, and that a new one is at its prompt. A compaction's status
11
+ // depends on what came before it, so the machine keeps it. SessionEnd comes on /clear too, while Claude goes on: whether
12
+ // Claude ended is read off its process (machine.ts), which the hook only has looked at sooner.
13
+ export const HOOK_EVENTS = [...Object.keys(TRANSITIONS), 'SessionStart', 'SessionEnd', 'PreCompact', 'PostCompact'];
14
+ // Notifications that wait on the user: a permission prompt (which is also how a question from Claude
15
+ // shows) and an MCP server asking for input. Claude also notifies when idle at its prompt for a minute,
16
+ // which is not a wait; one without a type comes from a version that predates the types.
17
+ const ASKS = new Set(['permission_prompt', 'elicitation_dialog']);
18
+ export function nextStatus(prev, event, notificationType) {
19
+ if (event === 'Notification' && notificationType !== undefined && !ASKS.has(notificationType))
20
+ return prev;
21
+ // Asked something before it started (trust this folder?), it starts once that is answered.
22
+ if (event === 'SessionStart')
23
+ return prev === 'starting' || prev === 'waiting' ? 'idle' : prev;
24
+ return TRANSITIONS[event] ?? prev;
25
+ }
26
+ export function notificationFor(prev, next, title) {
27
+ if (!next || prev === next)
28
+ return null;
29
+ if (next === 'waiting')
30
+ return { title, event: 'waiting' };
31
+ if (next === 'idle' && prev === 'working')
32
+ return { title, event: 'idle' };
33
+ return null;
34
+ }
@@ -0,0 +1,79 @@
1
+ import { askedOf, leftToScreen } from './asks.js';
2
+ import { heldInTranscript, lastTurn, transcriptTail } from '../transcript/turn.js';
3
+ import { nextStatus, notificationFor } from './status.js';
4
+ import { shownTitle } from '../../../system/store.js';
5
+ // How long a turn that ended with a message still in Claude's own queue waits for Claude to start the turn
6
+ // that message makes, before it is taken as over after all.
7
+ export const HELD_WAIT_MS = 5000;
8
+ // Hook events after which no permission prompt of the session is still open, and those that say its tool ran.
9
+ const ASKS_OVER = new Set(['UserPromptSubmit', 'Stop', 'StopFailure', 'SessionEnd']);
10
+ const TOOL_DONE = new Set(['PostToolUse', 'PostToolUseFailure']);
11
+ function mover(machine, notifier) {
12
+ return (termId, event, type, said = '') => {
13
+ const meta = machine.sessionMeta(termId);
14
+ if (!meta)
15
+ return;
16
+ const prev = machine.getStatus(termId);
17
+ const next = nextStatus(prev, event, type);
18
+ if (!next || next === prev)
19
+ return;
20
+ machine.setStatus(termId, next);
21
+ const n = notificationFor(prev, next, shownTitle(meta));
22
+ if (n)
23
+ void notifier.notify(termId, n.title, n.event, said).catch(() => { });
24
+ };
25
+ }
26
+ // A permission prompt is a wait from the moment its hook comes, seconds before Claude Code's notification,
27
+ // which says only that one is open.
28
+ export function createPermissionHandler(machine, notifier) {
29
+ const move = mover(machine, notifier);
30
+ return (termId, payload, hook) => {
31
+ if (machine.holdPermission(termId, payload, hook) || leftToScreen(payload))
32
+ move(termId, 'Notification', 'permission_prompt', askedOf(payload));
33
+ };
34
+ }
35
+ // What a hook's event was about, in Claude's words: the reply a turn ended with, or what an MCP server asks.
36
+ // Claude Code's own notification of a prompt names no more than that one is open.
37
+ function saidIn(payload, event) {
38
+ const said = event === 'Notification' ? (payload.notification_type === 'elicitation_dialog' ? payload.message : '') : payload.last_assistant_message;
39
+ return typeof said === 'string' ? said : '';
40
+ }
41
+ export function createHookHandler(machine, notifier) {
42
+ const held = new Map();
43
+ const move = mover(machine, notifier);
44
+ return (termId, payload) => {
45
+ if (!machine.sessionMeta(termId))
46
+ return;
47
+ if (typeof payload.session_id === 'string')
48
+ machine.setClaudeSessionId(termId, payload.session_id);
49
+ void machine.ingest(termId);
50
+ const event = String(payload.hook_event_name ?? '');
51
+ if (TOOL_DONE.has(event))
52
+ machine.asks.toolDone(termId, payload.tool_name, payload.tool_input);
53
+ if (ASKS_OVER.has(event))
54
+ machine.asks.endAll(termId);
55
+ clearTimeout(held.get(termId));
56
+ held.delete(termId);
57
+ if (event === 'SessionEnd')
58
+ return machine.claudeMayHaveQuit(termId);
59
+ if (event === 'PreCompact')
60
+ return machine.compactStarted(termId);
61
+ if (event === 'PostCompact')
62
+ return machine.compactEnded(termId, payload.trigger === 'manual');
63
+ // Claude goes on at once with what was typed into its own queue while it worked (a message sent now,
64
+ // out of its turn), however the turn ended: it is not over, and what waits here waits for the next.
65
+ const ended = event === 'Stop' || event === 'StopFailure';
66
+ if (ended && typeof payload.transcript_path === 'string' && heldInTranscript(payload.transcript_path).length > 0) {
67
+ held.set(termId, setTimeout(() => {
68
+ held.delete(termId);
69
+ // Started with no hook to say so: the next Stop ends it.
70
+ const lines = transcriptTail(payload.transcript_path);
71
+ if (lines && lastTurn(lines)?.over === false)
72
+ return;
73
+ move(termId, event, undefined, saidIn(payload, event));
74
+ }, HELD_WAIT_MS));
75
+ return;
76
+ }
77
+ move(termId, event, typeof payload.notification_type === 'string' ? payload.notification_type : undefined, saidIn(payload, event));
78
+ };
79
+ }
@@ -0,0 +1,219 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { initialized } from './initialize.js';
4
+ // Claude Code's own commands live in its binary, not in files, so these are written down here, for when
5
+ // Claude Code cannot be asked for its list (initialize.ts).
6
+ const BUILTIN = [
7
+ ['agents', 'Manage subagents'],
8
+ ['clear', 'Clear the conversation and start over'],
9
+ ['compact', 'Compact the conversation to free up context'],
10
+ ['config', 'Open settings'],
11
+ ['context', 'Show how much context is used'],
12
+ ['cost', 'Show what this session has cost'],
13
+ ['doctor', 'Check the Claude Code installation'],
14
+ ['export', 'Export the conversation'],
15
+ ['help', 'Help'],
16
+ ['hooks', 'Manage hooks'],
17
+ ['init', 'Write a CLAUDE.md for the project'],
18
+ ['mcp', 'Manage MCP servers'],
19
+ ['memory', 'Edit memory files'],
20
+ ['model', 'Switch the model'],
21
+ ['permissions', 'Manage permissions'],
22
+ ['plugin', 'Manage plugins'],
23
+ ['resume', 'Resume an earlier conversation'],
24
+ ['review', 'Review a pull request'],
25
+ ['rewind', 'Go back to an earlier point'],
26
+ ['status', 'Show version, account and connections'],
27
+ ['todos', 'Show the todo list'],
28
+ ['usage', 'Show how much of the plan is used'],
29
+ ];
30
+ const DESCRIPTION_MAX = 120;
31
+ async function readText(path) {
32
+ try {
33
+ return await readFile(path, 'utf8');
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ async function readJson(path) {
40
+ const text = await readText(path);
41
+ if (text === null)
42
+ return null;
43
+ try {
44
+ return JSON.parse(text);
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ async function entries(dir) {
51
+ try {
52
+ return (await readdir(dir, { withFileTypes: true })).filter((e) => !e.name.startsWith('.'));
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ const unquote = (v) => (/^(["']).*\1$/.test(v) ? v.slice(1, -1) : v);
59
+ // The simple keys of a markdown file's front matter, and the text after it. A value of `>` or `|` runs
60
+ // on over the indented lines below it.
61
+ function frontMatter(text) {
62
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
63
+ if (!m)
64
+ return { fields: {}, body: text };
65
+ const fields = {};
66
+ const lines = m[1].split(/\r?\n/);
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[i]);
69
+ if (!kv)
70
+ continue;
71
+ let value = kv[2].trim();
72
+ if (value === '' || /^[>|][+-]?$/.test(value)) {
73
+ const more = [];
74
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]))
75
+ more.push(lines[++i].trim());
76
+ value = more.join(' ');
77
+ }
78
+ fields[kv[1]] = unquote(value);
79
+ }
80
+ return { fields, body: text.slice(m[0].length) };
81
+ }
82
+ function oneLine(text) {
83
+ const flat = text.replace(/\s+/g, ' ').trim();
84
+ return flat.length <= DESCRIPTION_MAX ? flat : `${flat.slice(0, DESCRIPTION_MAX - 1).trimEnd()}…`;
85
+ }
86
+ const firstLine = (body) => body.split(/\r?\n/).map((l) => l.replace(/^#+\s*/, '').trim()).find((l) => l) ?? '';
87
+ function describe(text, fallbackName) {
88
+ const { fields, body } = frontMatter(text);
89
+ const lines = body.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#'));
90
+ return {
91
+ name: fields.name || fallbackName,
92
+ description: oneLine(fields.description || lines[0] || firstLine(body)),
93
+ hidden: fields['user-invocable'] === 'false',
94
+ };
95
+ }
96
+ // <dir>/<folder>/SKILL.md, named by its front matter, else by its folder.
97
+ async function skillsIn(dir) {
98
+ const found = [];
99
+ for (const e of await entries(dir)) {
100
+ if (!e.isDirectory() && !e.isSymbolicLink())
101
+ continue;
102
+ const text = await readText(join(dir, e.name, 'SKILL.md'));
103
+ if (text === null)
104
+ continue;
105
+ const s = describe(text, e.name);
106
+ if (!s.hidden)
107
+ found.push({ name: s.name, description: s.description });
108
+ }
109
+ return found;
110
+ }
111
+ // <dir>/**/<name>.md, a folder becoming `folder:` in front of the name.
112
+ async function commandsIn(dir, prefix = '') {
113
+ const found = [];
114
+ for (const e of await entries(dir)) {
115
+ const path = join(dir, e.name);
116
+ if (e.isDirectory())
117
+ found.push(...(await commandsIn(path, `${prefix}${e.name}:`)));
118
+ else if (e.name.endsWith('.md')) {
119
+ const text = await readText(path);
120
+ if (text !== null)
121
+ found.push({ name: prefix + e.name.slice(0, -3), description: describe(text, '').description });
122
+ }
123
+ }
124
+ return found;
125
+ }
126
+ const both = async (root) => [...(await skillsIn(join(root, 'skills'))), ...(await commandsIn(join(root, 'commands')))];
127
+ const record = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
128
+ // The plugins switched on for a session in `cwd`: the user's settings, then the project's, then its local ones.
129
+ async function enabledPlugins(claudeDir, cwd) {
130
+ const on = new Map();
131
+ for (const path of [join(claudeDir, 'settings.json'), join(cwd, '.claude', 'settings.json'), join(cwd, '.claude', 'settings.local.json')]) {
132
+ for (const [key, value] of Object.entries(record(record(await readJson(path)).enabledPlugins)))
133
+ on.set(key, value === true);
134
+ }
135
+ return new Set([...on].filter(([, v]) => v).map(([k]) => k));
136
+ }
137
+ async function pluginCommands(claudeDir, cwd) {
138
+ const enabled = await enabledPlugins(claudeDir, cwd);
139
+ const installed = record(record(await readJson(join(claudeDir, 'plugins', 'installed_plugins.json'))).plugins);
140
+ const found = [];
141
+ for (const [key, installs] of Object.entries(installed)) {
142
+ if (!enabled.has(key) || !Array.isArray(installs))
143
+ continue;
144
+ const install = installs.map(record).find((i) => i.scope === 'user' || i.projectPath === cwd);
145
+ if (typeof install?.installPath !== 'string')
146
+ continue;
147
+ const plugin = key.split('@')[0];
148
+ for (const c of await both(install.installPath))
149
+ found.push({ ...c, name: `${plugin}:${c.name}` });
150
+ }
151
+ return found;
152
+ }
153
+ // Commands Claude Code takes only in its terminal, which it leaves out of the list it gives a program.
154
+ const TERMINAL_ONLY = [
155
+ ['add-dir', 'Add a new working directory'],
156
+ ['btw', 'Ask a quick side question without interrupting the main conversation'],
157
+ ['export', 'Export the current conversation to a file or clipboard'],
158
+ ['help', 'Show help and available commands'],
159
+ ['hooks', 'Manage hooks'],
160
+ ['memory', 'Edit CLAUDE.md files and memory settings'],
161
+ ['permissions', 'Manage permissions'],
162
+ ['plugin', 'Manage plugins'],
163
+ ['resume', 'Resume a previous conversation'],
164
+ ['rewind', 'Restore the code and/or conversation to a previous point'],
165
+ ['status', 'Show Claude Code status including version, model, account, API connectivity, and tool statuses'],
166
+ ['tasks', 'View and manage everything running in the background'],
167
+ ];
168
+ // Claude Code says where a command of the user's or the project's comes from after its description, and a
169
+ // plugin's before it.
170
+ function fromTold(c) {
171
+ // `__` marks one of its own, which its menu does not show.
172
+ if (typeof c.name !== 'string' || !c.name || c.name.startsWith('__'))
173
+ return null;
174
+ const said = typeof c.description === 'string' ? c.description : '';
175
+ const source = c.builtin === true ? 'builtin' : c.name.includes(':') ? 'plugin' : /\(project\)\s*$/.test(said) ? 'project' : 'user';
176
+ const unsourced = said.replace(/\s*\((user|project)\)\s*$/, '');
177
+ const description = source === 'plugin' ? unsourced.replace(/^\([^)]*\)\s*/, '') : unsourced;
178
+ return { name: c.name, description: oneLine(description), source };
179
+ }
180
+ const byNameOrder = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
181
+ // What can follow a `/` in a Claude session started in `cwd`: Claude Code's own list when `claudeBin` can be
182
+ // asked for it, else read from the same files Claude Code reads.
183
+ export async function listCommands(o) {
184
+ if (o.claudeBin && o.cwd) {
185
+ const told = await initialized(o.claudeBin, o.cwd).catch(() => null);
186
+ const list = Array.isArray(told?.commands) ? told.commands : null;
187
+ if (list) {
188
+ const byName = new Map();
189
+ for (const c of list.map(fromTold))
190
+ if (c && !byName.has(c.name))
191
+ byName.set(c.name, c);
192
+ for (const [name, description] of TERMINAL_ONLY)
193
+ if (!byName.has(name))
194
+ byName.set(name, { name, description, source: 'builtin' });
195
+ return [...byName.values()].sort(byNameOrder);
196
+ }
197
+ }
198
+ return filesListed(o);
199
+ }
200
+ // Read from the same files Claude Code reads. Where two share a name, the earlier source wins: built-in,
201
+ // then the project, the user, a plugin.
202
+ async function filesListed(o) {
203
+ const [project, user, plugin] = await Promise.all([
204
+ o.cwd ? both(join(o.cwd, '.claude')) : [],
205
+ both(o.claudeDir),
206
+ pluginCommands(o.claudeDir, o.cwd),
207
+ ]);
208
+ const byName = new Map();
209
+ const add = (source, list) => {
210
+ for (const c of list)
211
+ if (c.name && !byName.has(c.name))
212
+ byName.set(c.name, { ...c, source });
213
+ };
214
+ add('builtin', BUILTIN.map(([name, description]) => ({ name, description })));
215
+ add('project', project);
216
+ add('user', user);
217
+ add('plugin', plugin);
218
+ return [...byName.values()].sort(byNameOrder);
219
+ }
@@ -0,0 +1,51 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const run = promisify(execFile);
6
+ // How many paths are offered at most, how deep a folder that is not a git repository is walked, and for how
7
+ // long a listing is reused.
8
+ const MAX_FILES = 5000;
9
+ const MAX_DEPTH = 3;
10
+ const GIT_TIMEOUT_MS = 3000;
11
+ const KEPT_MS = 30_000;
12
+ const SKIPPED = new Set(['node_modules', 'dist', 'build', 'target', 'vendor', '__pycache__']);
13
+ const kept = new Map();
14
+ // The files under cwd an `@` can name, relative to it: what git tracks or would (ignored ones left out);
15
+ // outside a repository, a shallow walk past dot folders and dependency folders.
16
+ export function listFiles(cwd, now = Date.now()) {
17
+ const hit = kept.get(cwd);
18
+ if (hit && now - hit.at < KEPT_MS)
19
+ return hit.files;
20
+ const files = gitFiles(cwd).catch(() => walk(cwd));
21
+ kept.set(cwd, { at: now, files });
22
+ files.catch(() => kept.delete(cwd));
23
+ return files;
24
+ }
25
+ async function gitFiles(cwd) {
26
+ const { stdout } = await run('git', ['ls-files', '-co', '--exclude-standard'], { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 });
27
+ return stdout.split('\n').filter(Boolean).slice(0, MAX_FILES);
28
+ }
29
+ async function walk(cwd) {
30
+ const files = [];
31
+ const visit = async (rel, depth) => {
32
+ if (files.length >= MAX_FILES)
33
+ return;
34
+ const entries = await readdir(join(cwd, rel), { withFileTypes: true }).catch(() => []);
35
+ for (const e of entries) {
36
+ if (files.length >= MAX_FILES)
37
+ return;
38
+ if (e.name.startsWith('.') || SKIPPED.has(e.name))
39
+ continue;
40
+ const path = rel ? `${rel}/${e.name}` : e.name;
41
+ if (e.isDirectory()) {
42
+ if (depth < MAX_DEPTH)
43
+ await visit(path, depth + 1);
44
+ }
45
+ else if (e.isFile())
46
+ files.push(path);
47
+ }
48
+ };
49
+ await visit('', 1);
50
+ return files;
51
+ }
@@ -0,0 +1,59 @@
1
+ import { spawn } from 'node:child_process';
2
+ const ASK_MS = 15_000;
3
+ const KEPT_MS = 5 * 60_000;
4
+ const told = new Map();
5
+ // Claude Code's answer in `cwd`, kept a while: it changes only with its settings, plugins and account.
6
+ export function initialized(bin, cwd) {
7
+ const key = `${bin}\n${cwd}`;
8
+ const kept = told.get(key);
9
+ if (kept && Date.now() - kept.at < KEPT_MS)
10
+ return kept.answer;
11
+ const answer = askClaude(bin, cwd);
12
+ told.set(key, { at: Date.now(), answer });
13
+ answer.catch(() => told.get(key)?.answer === answer && told.delete(key));
14
+ return answer;
15
+ }
16
+ // Asked as a program asks it (`-p`, stream-json) before any turn, so nothing goes to the model; without hooks
17
+ // or a transcript, so it leaves no trace.
18
+ function askClaude(bin, cwd) {
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawn(bin, ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--no-session-persistence', '--settings', JSON.stringify({ disableAllHooks: true })], { cwd, stdio: ['pipe', 'pipe', 'ignore'] });
21
+ let done = false;
22
+ const finish = (error, answer = {}) => {
23
+ if (done)
24
+ return;
25
+ done = true;
26
+ clearTimeout(timer);
27
+ child.kill();
28
+ if (error)
29
+ reject(error);
30
+ else
31
+ resolve(answer);
32
+ };
33
+ const timer = setTimeout(() => finish(new Error('claude did not answer initialize')), ASK_MS);
34
+ child.on('error', finish);
35
+ child.on('exit', () => finish(new Error('claude ended before answering initialize')));
36
+ let out = '';
37
+ child.stdout.setEncoding('utf8');
38
+ child.stdout.on('data', (chunk) => {
39
+ out += chunk;
40
+ for (let end = out.indexOf('\n'); end >= 0; end = out.indexOf('\n')) {
41
+ const line = out.slice(0, end);
42
+ out = out.slice(end + 1);
43
+ let msg;
44
+ try {
45
+ msg = JSON.parse(line);
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ if (msg?.type !== 'control_response')
51
+ continue;
52
+ const answer = msg.response?.response;
53
+ return answer && typeof answer === 'object' ? finish(null, answer) : finish(new Error('claude answered initialize with nothing'));
54
+ }
55
+ });
56
+ child.stdin.on('error', () => { });
57
+ child.stdin.end(`${JSON.stringify({ type: 'control_request', request_id: 'initialize', request: { subtype: 'initialize' } })}\n`);
58
+ });
59
+ }
@@ -0,0 +1,20 @@
1
+ import { initialized } from './initialize.js';
2
+ const str = (v) => (typeof v === 'string' ? v : '');
3
+ // What /model is given is typed at the prompt: a name, an id, `opus[1m]`, never anything else.
4
+ const VALUE = /^[\w.:[\]-]{1,40}$/;
5
+ // The models Claude Code offers in `cwd`, in its own order; empty when it cannot be asked.
6
+ export async function listModels(bin, cwd) {
7
+ const told = await initialized(bin, cwd).catch(() => null);
8
+ if (!Array.isArray(told?.models))
9
+ return [];
10
+ return told.models.flatMap((m) => {
11
+ if (!m || typeof m !== 'object')
12
+ return [];
13
+ const r = m;
14
+ const value = str(r.value);
15
+ if (!VALUE.test(value))
16
+ return [];
17
+ const efforts = r.supportsEffort === true && Array.isArray(r.supportedEffortLevels) ? r.supportedEffortLevels.filter((l) => typeof l === 'string') : [];
18
+ return [{ value, name: str(r.displayName) || value, about: str(r.description), efforts }];
19
+ });
20
+ }