kapi-ui 0.2.2 → 0.3.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.
@@ -0,0 +1,181 @@
1
+ :host {
2
+ /* Reset anything inheritable coming from the host page. */
3
+ all: initial;
4
+
5
+ --kapi-bg: #1e1e1f;
6
+ --kapi-bg-hover: #3b3b3f;
7
+ --kapi-bg-active: #3b3b3f;
8
+ --kapi-ring: rgba(255, 255, 255, 0.14);
9
+ --kapi-divider: rgba(255, 255, 255, 0.16);
10
+
11
+ position: fixed;
12
+ top: 0;
13
+ left: 0;
14
+ z-index: 2147483647;
15
+ color-scheme: dark;
16
+ touch-action: none;
17
+ }
18
+
19
+ .kapi-bar {
20
+ display: flex;
21
+ align-items: center;
22
+ box-sizing: border-box;
23
+ width: 40px;
24
+ height: 40px;
25
+ padding: 4px;
26
+ gap: 6px;
27
+ border-radius: 12px;
28
+ background: var(--kapi-bg);
29
+ box-shadow:
30
+ inset 0 0 0 1px var(--kapi-ring),
31
+ 0 2px 4px rgba(0, 0, 0, 0.2),
32
+ 0 8px 16px rgba(0, 0, 0, 0.2);
33
+ overflow: hidden;
34
+ transition:
35
+ width 260ms cubic-bezier(0.34, 1.56, 0.64, 1),
36
+ border-radius 260ms ease;
37
+ transition-delay: 120ms, 120ms;
38
+ }
39
+
40
+ .kapi-bar.kapi-expanded {
41
+ width: var(--kapi-expanded-width, 40px);
42
+ border-radius: 12px;
43
+ transition-delay: 0ms, 0ms;
44
+ }
45
+
46
+ .kapi-btn {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ box-sizing: border-box;
51
+ flex: none;
52
+ width: 32px;
53
+ height: 32px;
54
+ padding: 0;
55
+ margin: 0;
56
+ border: none;
57
+ border-radius: 8px;
58
+ background: transparent;
59
+ cursor: pointer;
60
+ user-select: none;
61
+ -webkit-tap-highlight-color: transparent;
62
+ transition: background 150ms ease, transform 150ms ease;
63
+ }
64
+
65
+ .kapi-btn:hover { background: var(--kapi-bg-hover); }
66
+ .kapi-btn:active { background: var(--kapi-bg-active); transform: scale(0.92); transition: none; }
67
+ .kapi-btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
68
+ .kapi-logo-btn { cursor: pointer; touch-action: none; }
69
+
70
+ .kapi-logo-btn.kapi-dragging {
71
+ cursor: grabbing;
72
+ background: var(--kapi-bg-active);
73
+ transform: scale(0.95);
74
+ transition: none;
75
+ }
76
+
77
+ .kapi-icon { pointer-events: none; }
78
+
79
+ .kapi-logo-icon {
80
+ width: 20px;
81
+ height: auto;
82
+ margin-top: 2px;
83
+ animation: kapi-bounce 1.6s ease-in-out infinite;
84
+ }
85
+
86
+ .kapi-logo-btn.kapi-dragging .kapi-logo-icon { animation-play-state: paused; }
87
+
88
+ @keyframes kapi-bounce {
89
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
90
+ 50% { transform: translateY(-3px) rotate(-6deg); }
91
+ }
92
+
93
+ @keyframes kapi-icon-fade-out {
94
+ from { opacity: 1; transform: scale(1); }
95
+ to { opacity: 0; transform: scale(0.8); }
96
+ }
97
+
98
+ @keyframes kapi-icon-fade-in {
99
+ from { opacity: 0; transform: scale(0.8); }
100
+ to { opacity: 1; transform: scale(1); }
101
+ }
102
+
103
+ .kapi-logo-btn.kapi-animating-out { animation: kapi-icon-fade-out 200ms ease; }
104
+ .kapi-logo-btn.kapi-animating-in { animation: kapi-icon-fade-in 200ms ease; }
105
+ .kapi-ai-icon { width: 16.5px; height: auto; }
106
+ .kapi-copy-icon { width: 16.5px; height: auto; color: white; }
107
+ .kapi-check-icon { width: 16.5px; height: auto; color: rgb(34, 197, 94); }
108
+
109
+ /* Copy button success feedback: swap copy <-> checkmark with a
110
+ fade/scale/blur transition. .kapi-busy fully blocks clicks meanwhile. */
111
+ .kapi-btn.kapi-busy { pointer-events: none; }
112
+ .kapi-copy-out { animation: kapi-copy-icon-out 180ms ease forwards; }
113
+ .kapi-copy-in { animation: kapi-copy-icon-in 180ms ease forwards; }
114
+
115
+ @keyframes kapi-copy-icon-out {
116
+ from { opacity: 1; transform: scale(1); filter: blur(0); }
117
+ to { opacity: 0; transform: scale(0.6); filter: blur(3px); }
118
+ }
119
+
120
+ @keyframes kapi-copy-icon-in {
121
+ from { opacity: 0; transform: scale(0.6); filter: blur(3px); }
122
+ to { opacity: 1; transform: scale(1); filter: blur(0); }
123
+ }
124
+
125
+ .kapi-delete-icon {
126
+ width: 15px;
127
+ height: auto;
128
+ color: white;
129
+ transition: color 150ms ease;
130
+ }
131
+
132
+ .kapi-btn:has(.kapi-delete-icon):hover,
133
+ .kapi-btn:has(.kapi-delete-icon):active { background: rgba(255, 77, 79, 0.15); }
134
+
135
+ .kapi-btn:hover .kapi-delete-icon,
136
+ .kapi-btn:active .kapi-delete-icon { color: #ff4d4f; }
137
+
138
+ .kapi-stop-icon {
139
+ width: 20px;
140
+ height: auto;
141
+ transition: stroke 150ms ease, color 150ms ease;
142
+ }
143
+
144
+ .kapi-logo-btn.kapi-stop-mode:hover .kapi-stop-icon,
145
+ .kapi-logo-btn.kapi-stop-mode:active .kapi-stop-icon { stroke: #ff4d4f; color: #ff4d4f; }
146
+
147
+ .kapi-logo-btn.kapi-stop-mode:hover,
148
+ .kapi-logo-btn.kapi-stop-mode:active { background: rgba(255, 77, 79, 0.15); }
149
+
150
+ .kapi-extra {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 6px;
154
+ flex: none;
155
+ opacity: 0.6;
156
+ transform: scale(0.9);
157
+ filter: blur(4px);
158
+ transition: opacity 160ms ease, transform 160ms ease, filter 160ms ease;
159
+ transition-delay: 0ms;
160
+ }
161
+
162
+ .kapi-bar.kapi-expanded .kapi-extra {
163
+ opacity: 1;
164
+ transform: scale(1);
165
+ filter: blur(0);
166
+ transition-delay: 140ms;
167
+ }
168
+
169
+ .kapi-btn-group { display: flex; align-items: center; gap: 0; flex: none; }
170
+
171
+ .kapi-divider {
172
+ flex: none;
173
+ width: 1px;
174
+ height: 18px;
175
+ background: var(--kapi-divider);
176
+ }
177
+
178
+ @media (prefers-reduced-motion: reduce) {
179
+ .kapi-bar, .kapi-btn, .kapi-extra { transition: none; }
180
+ .kapi-logo-icon { animation: none; }
181
+ }
@@ -0,0 +1,33 @@
1
+ // Vue re-creates vnode objects on every re-render, but a given template
2
+ // position's `props` object is a stable identity for that call site within a
3
+ // single render pass — good enough to key a lookup that only needs to survive
4
+ // from "vnode created" to "el mounted" a few lines later in the same tick.
5
+ const vnodeToPos = new WeakMap();
6
+ /**
7
+ * Called by the wrapper the vite plugin injects around every vnode-creation
8
+ * call (`_createElementVNode`, etc.) in a component's compiled render
9
+ * function. Tags the vnode's `props` object with where that call came from in
10
+ * the original `.vue` source, so it can be recovered later via `el.__vnode`.
11
+ */
12
+ export function recordPosition(file, line, column, vnode) {
13
+ const node = vnode;
14
+ // `h(...)` (unlike the underscore-prefixed compiler helpers) can also be a
15
+ // user-defined identifier unrelated to Vue's hyperscript function — only
16
+ // tag objects Vue itself marked as vnodes, so a colliding `h` never gets
17
+ // mutated with a spurious `.props`.
18
+ if (!node || typeof node !== 'object' || node.__v_isVNode !== true)
19
+ return vnode;
20
+ const props = (node.props ?? (node.props = {}));
21
+ vnodeToPos.set(props, { file, line, column });
22
+ return vnode;
23
+ }
24
+ /**
25
+ * Reads Vue's own non-enumerable `el.__vnode` back-reference (set by Vue's
26
+ * renderer on every DOM node it creates) to recover the source location
27
+ * recorded for that exact element, if any.
28
+ */
29
+ export function findTraceFromElement(el) {
30
+ const vnode = el.__vnode;
31
+ const props = vnode?.props;
32
+ return (props && vnodeToPos.get(props)) ?? null;
33
+ }
@@ -0,0 +1,4 @@
1
+ // Shared type definitions for the browser tier (overlay, inspector,
2
+ // comments, trace-record). Kept separate from implementation so the shapes
3
+ // can be referenced without pulling in a module's runtime code.
4
+ export {};
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ import { stdin as input, stdout as output } from 'process';
3
+ import { installKapi, injectVitePlugin, injectNuxtModule, detectFramework, detectInstalledAgents, KAPI_PACKAGE_NAME, } from './utils.js';
4
+ // How the chosen agent renders in config: a quoted agent name, or bare `false`
5
+ // for the manual copy/paste-only workflow.
6
+ const agentConfigValue = (agent) => (agent === false ? 'false' : `'${agent}'`);
7
+ const FRAMEWORK_SETUP = {
8
+ nuxt: {
9
+ label: 'Nuxt',
10
+ inject: injectNuxtModule,
11
+ manualInstructions: (agent) => `
12
+ Add this manually to your nuxt.config:
13
+
14
+ export default defineNuxtConfig({
15
+ modules: ['${KAPI_PACKAGE_NAME}/nuxt'],
16
+ kapi: { agent: ${agentConfigValue(agent)} },
17
+ })
18
+ `,
19
+ },
20
+ vite: {
21
+ label: 'Vite + Vue',
22
+ inject: injectVitePlugin,
23
+ manualInstructions: (agent) => `
24
+ Add this manually to your vite.config:
25
+
26
+ import kapi from '${KAPI_PACKAGE_NAME}/vite-plugin'
27
+
28
+ export default defineConfig({
29
+ plugins: [kapi({ agent: ${agentConfigValue(agent)} })],
30
+ })
31
+ `,
32
+ },
33
+ };
34
+ const AGENT_LABELS = {
35
+ claude: 'Claude Code',
36
+ codex: 'Codex (experimental)',
37
+ };
38
+ const modeLabel = (agent) => (agent === false ? 'manual copy/paste' : AGENT_LABELS[agent]);
39
+ // Minimal dependency-free single-select list: renders bulleted options and
40
+ // lets the user move with ↑/↓ (or j/k) and confirm with Enter, redrawing the
41
+ // list in place. Falls back to the default option when stdin isn't a TTY
42
+ // (piped/CI), where raw-mode key reading isn't available.
43
+ function promptSelect(question, options, defaultIndex = 0) {
44
+ return new Promise((resolve) => {
45
+ if (!input.isTTY) {
46
+ resolve(options[defaultIndex].value);
47
+ return;
48
+ }
49
+ let index = defaultIndex;
50
+ const render = (redraw) => {
51
+ if (redraw)
52
+ output.write(`[${options.length}A`); // move up over the option lines
53
+ for (let i = 0; i < options.length; i++) {
54
+ const selected = i === index;
55
+ // ● highlighted (cyan) for the current row, ○ for the rest.
56
+ const line = selected ? `❯ ● ${options[i].label}` : ` ○ ${options[i].label}`;
57
+ output.write(`${line}\n`); // clear line, then draw
58
+ }
59
+ };
60
+ const cleanup = () => {
61
+ input.off('data', onData);
62
+ input.setRawMode(false);
63
+ input.pause();
64
+ output.write('[?25h'); // show cursor
65
+ };
66
+ const onData = (data) => {
67
+ const key = data.toString();
68
+ if (key === '') {
69
+ // Ctrl+C
70
+ cleanup();
71
+ output.write('\n');
72
+ process.exit(130);
73
+ }
74
+ else if (key === '' || key === 'k') {
75
+ index = (index - 1 + options.length) % options.length;
76
+ render(true);
77
+ }
78
+ else if (key === '' || key === 'j') {
79
+ index = (index + 1) % options.length;
80
+ render(true);
81
+ }
82
+ else if (key === '\r' || key === '\n') {
83
+ cleanup();
84
+ resolve(options[index].value);
85
+ }
86
+ };
87
+ output.write(`${question}\n(↑/↓ to move · Enter to select)\n`);
88
+ output.write('[?25l'); // hide cursor
89
+ render(false);
90
+ input.setRawMode(true);
91
+ input.resume();
92
+ input.on('data', onData);
93
+ });
94
+ }
95
+ function parseFrameworkFlag() {
96
+ const args = process.argv.slice(2);
97
+ if (args.includes('--nuxt'))
98
+ return 'nuxt';
99
+ if (args.includes('--vite'))
100
+ return 'vite';
101
+ return null;
102
+ }
103
+ function parseAgentFlag() {
104
+ const args = process.argv.slice(2);
105
+ const inlineValue = args.find((arg) => arg.startsWith('--agent='))?.split('=')[1];
106
+ if (inlineValue === 'claude' || inlineValue === 'codex')
107
+ return inlineValue;
108
+ if (inlineValue)
109
+ throw new Error(`Unknown agent "${inlineValue}". Use "claude" or "codex".`);
110
+ const flagIndex = args.indexOf('--agent');
111
+ const value = flagIndex === -1 ? undefined : args[flagIndex + 1];
112
+ if (flagIndex === -1)
113
+ return null;
114
+ if (value === 'claude' || value === 'codex')
115
+ return value;
116
+ throw new Error('Missing or invalid --agent value. Use "claude" or "codex".');
117
+ }
118
+ // `--manual` (or `--agent=false`/`--agent=none`) forces the copy/paste-only
119
+ // workflow without prompting.
120
+ function parseManualFlag() {
121
+ const args = process.argv.slice(2);
122
+ if (args.includes('--manual'))
123
+ return true;
124
+ const inline = args.find((arg) => arg.startsWith('--agent='))?.split('=')[1];
125
+ return inline === 'false' || inline === 'none';
126
+ }
127
+ async function chooseMode() {
128
+ if (parseManualFlag())
129
+ return false;
130
+ const requestedAgent = parseAgentFlag();
131
+ const installedAgents = detectInstalledAgents();
132
+ if (requestedAgent) {
133
+ if (!installedAgents.includes(requestedAgent)) {
134
+ throw new Error(`${requestedAgent} CLI is not installed. Install it, or run setup with --manual.`);
135
+ }
136
+ return requestedAgent;
137
+ }
138
+ // No agent installed → nothing to send to, so set up the manual workflow.
139
+ if (installedAgents.length === 0) {
140
+ console.log('No coding agent (Claude Code or Codex) detected — setting up the manual copy/paste workflow.');
141
+ return false;
142
+ }
143
+ const useAgent = await promptSelect('How should Kapi handle your comments?', [
144
+ { label: 'Send them to a coding agent', value: true },
145
+ { label: 'Manual copy & paste only', value: false },
146
+ ]);
147
+ if (!useAgent)
148
+ return false;
149
+ if (installedAgents.length === 1)
150
+ return installedAgents[0];
151
+ return promptSelect('Which coding agent should Kapi use?', installedAgents.map((agent) => ({ label: AGENT_LABELS[agent], value: agent })));
152
+ }
153
+ async function setup() {
154
+ console.log(`
155
+ ██ ██ █████ ██████ ██
156
+ ██ ██ ██ ██ ██ ██ ██
157
+ █████ ███████ ██████ ██
158
+ ██ ██ ██ ██ ██ ██
159
+ ██ ██ ██ ██ ██ ██
160
+ `);
161
+ const cwd = process.cwd();
162
+ const framework = parseFrameworkFlag() ?? detectFramework(cwd);
163
+ if (!framework) {
164
+ console.error(`Could not detect a Vue project in ${cwd}.`);
165
+ console.error(`kapi-ui only supports Vue apps (Vite + Vue, or Nuxt).`);
166
+ console.error(`If this is a Vue project, re-run with --vite or --nuxt to skip detection.`);
167
+ process.exit(1);
168
+ }
169
+ let agent;
170
+ try {
171
+ agent = await chooseMode();
172
+ }
173
+ catch (err) {
174
+ console.error(`Unable to choose a coding agent: ${err instanceof Error ? err.message : String(err)}`);
175
+ process.exit(1);
176
+ }
177
+ const { label, inject, manualInstructions } = FRAMEWORK_SETUP[framework];
178
+ console.log(`\n✨ Detected ${label}; using ${modeLabel(agent)} — setting up kapi...\n`);
179
+ try {
180
+ installKapi(cwd);
181
+ await inject(cwd, agent);
182
+ console.log('done!');
183
+ }
184
+ catch (err) {
185
+ console.error('Failed to update your config automatically:', err);
186
+ console.log(manualInstructions(agent));
187
+ process.exit(1);
188
+ }
189
+ }
190
+ setup().catch(console.error);
@@ -0,0 +1,2 @@
1
+ // Shared type definitions for the CLI tier (setup + utils).
2
+ export {};
@@ -3,18 +3,46 @@ import { addVitePlugin, addNuxtModule } from 'magicast/helpers';
3
3
  import { existsSync, readFileSync } from 'fs';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
- import { execSync } from 'child_process';
7
- // dist/utils.js -> package root (one level up from dist/)
8
- const kapiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
+ import { execSync, spawnSync } from 'child_process';
7
+ // dist/cli/utils.js -> package root (two levels up from dist/cli/)
8
+ const kapiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
9
9
  const kapiPkg = JSON.parse(readFileSync(path.join(kapiRoot, 'package.json'), 'utf-8'));
10
10
  export const KAPI_PACKAGE_NAME = kapiPkg.name;
11
11
  export function installKapi(cwd) {
12
12
  console.log(`Installing ${KAPI_PACKAGE_NAME}...`);
13
13
  execSync(`npm install ${KAPI_PACKAGE_NAME} -D`, { cwd, stdio: 'inherit' });
14
14
  }
15
- export async function injectVitePlugin(cwd) {
16
- const candidates = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
17
- const configFile = candidates.find((f) => existsSync(path.join(cwd, f)));
15
+ export const NUXT_CONFIG_CANDIDATES = ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs', 'nuxt.config.mts', 'nuxt.config.cjs'];
16
+ export const VITE_CONFIG_CANDIDATES = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.mts', 'vite.config.cjs'];
17
+ // kapi only supports Vue apps, so setup detects which of the two Vue build
18
+ // tools it's dealing with instead of asking — a nuxt.config file means Nuxt
19
+ // (Nuxt apps don't always list `vue` as a direct dependency), otherwise a
20
+ // `vue` dependency means a plain Vite + Vue app.
21
+ export function detectFramework(cwd) {
22
+ if (NUXT_CONFIG_CANDIDATES.some((f) => existsSync(path.join(cwd, f))))
23
+ return 'nuxt';
24
+ const pkgPath = path.join(cwd, 'package.json');
25
+ if (!existsSync(pkgPath))
26
+ return null;
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
29
+ const hasVue = Boolean(pkg.dependencies?.vue || pkg.devDependencies?.vue);
30
+ return hasVue ? 'vite' : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function isAgentInstalled(agent) {
37
+ const result = spawnSync(agent, ['--version'], { stdio: 'ignore' });
38
+ const error = result.error;
39
+ return error?.code !== 'ENOENT';
40
+ }
41
+ export function detectInstalledAgents() {
42
+ return ['claude', 'codex'].filter(isAgentInstalled);
43
+ }
44
+ export async function injectVitePlugin(cwd, agent) {
45
+ const configFile = VITE_CONFIG_CANDIDATES.find((f) => existsSync(path.join(cwd, f)));
18
46
  if (!configFile) {
19
47
  throw new Error('No vite.config found.');
20
48
  }
@@ -29,14 +57,14 @@ export async function injectVitePlugin(cwd) {
29
57
  addVitePlugin(mod, {
30
58
  from: importSpecifier,
31
59
  imported: 'default',
32
- constructor: 'kapi'
60
+ constructor: 'kapi',
61
+ options: { agent },
33
62
  });
34
63
  await writeFile(mod, configPath);
35
64
  console.log(`✔ Added kapi plugin to ${configFile}`);
36
65
  }
37
- export async function injectNuxtModule(cwd) {
38
- const candidates = ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs'];
39
- const configFile = candidates.find((f) => existsSync(path.join(cwd, f)));
66
+ export async function injectNuxtModule(cwd, agent) {
67
+ const configFile = NUXT_CONFIG_CANDIDATES.find((f) => existsSync(path.join(cwd, f)));
40
68
  if (!configFile) {
41
69
  throw new Error('No nuxt.config found.');
42
70
  }
@@ -52,7 +80,7 @@ export async function injectNuxtModule(cwd) {
52
80
  // overlay can't be injected as a plain Vite plugin the way it is for Vite
53
81
  // apps. Registering it as a Nuxt module (kapi-ui/nuxt) lets it inject the
54
82
  // overlay script via unhead and add the Vite plugin through @nuxt/kit.
55
- addNuxtModule(mod, moduleSpecifier);
83
+ addNuxtModule(mod, moduleSpecifier, 'kapi', { agent });
56
84
  await writeFile(mod, configPath);
57
85
  console.log(`✔ Added kapi module to ${configFile}`);
58
86
  }
@@ -0,0 +1,185 @@
1
+ // One warm `claude -p --input-format stream-json` process serves every
2
+ // submission, across every tab (they share one session anyway — see
3
+ // kapi/CLAUDE.md's Session Management section). This avoids CLI cold-start
4
+ // and --resume-from-disk overhead on each comment: the process, its
5
+ // tool-use context, and the prompt cache all stay warm between submissions.
6
+ import { spawn } from 'child_process';
7
+ const KAPI_SESSION_PROMPT = "You're applying UI edits from visual comments. Each comment gives the exact file:line:col — go straight there, don't search for it. Make only the requested edit. " +
8
+ "Before finishing, check whether a higher-precedence rule on the same element would override the property you just changed — an inline `style` attribute, `!important`, a more specific selector, or a utility class applied after it. You have no way to see the rendered result, so if such a conflict exists, edit the highest-precedence source instead of (or in addition to) the one you found first, so the change actually takes visible effect. " +
9
+ "Stay terse: no narration between tool calls, and finish with at most one short sentence.";
10
+ function describeToolUse(name, input) {
11
+ switch (name) {
12
+ case 'Read':
13
+ return `Reading ${input?.file_path ?? 'a file'}`;
14
+ case 'Edit':
15
+ case 'Write':
16
+ return `Editing ${input?.file_path ?? 'a file'}`;
17
+ case 'Bash':
18
+ return `Running: ${String(input?.command ?? '').slice(0, 60)}`;
19
+ case 'Grep':
20
+ return `Searching for "${input?.pattern ?? ''}"`;
21
+ case 'Glob':
22
+ return `Finding files matching ${input?.pattern ?? ''}`;
23
+ default:
24
+ return `Using ${name}`;
25
+ }
26
+ }
27
+ function sessionIdFromEvent(event) {
28
+ return typeof event?.session_id === 'string' ? event.session_id : null;
29
+ }
30
+ function describeEvent(event) {
31
+ if (event?.type !== 'assistant' || !event.message?.content)
32
+ return null;
33
+ for (const block of event.message.content) {
34
+ if (block.type === 'tool_use')
35
+ return describeToolUse(block.name, block.input);
36
+ if (block.type === 'text' && block.text)
37
+ return block.text.slice(0, 120);
38
+ // Thinking content isn't exposed by the API (comes back redacted/empty),
39
+ // but the block itself still arrives — surface it so "Starting..."
40
+ // doesn't sit frozen for the several seconds Claude spends reasoning
41
+ // before its first visible tool call.
42
+ if (block.type === 'thinking')
43
+ return 'Thinking...';
44
+ }
45
+ return null;
46
+ }
47
+ let cwd = process.cwd();
48
+ let sessionId = null;
49
+ let claudeProc = null;
50
+ let busy = false; // a turn is currently running on the process
51
+ // Which client to notify when the running turn finishes. Null for the
52
+ // startup turn that sends KAPI_SESSION_PROMPT — there's no tab yet.
53
+ let activeClient = null;
54
+ // FIFO queue of turns waiting for the process to free up. The very first
55
+ // entry, queued at server startup, is the KAPI_SESSION_PROMPT itself.
56
+ const pendingPrompts = [];
57
+ function ensureClaudeProc() {
58
+ if (claudeProc && claudeProc.exitCode === null)
59
+ return claudeProc;
60
+ const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--permission-mode', 'acceptEdits', '--allowedTools', 'Read,Edit,Write'];
61
+ if (sessionId)
62
+ args.push('--resume', sessionId);
63
+ let claude;
64
+ try {
65
+ claude = spawn('claude', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
66
+ }
67
+ catch (err) {
68
+ console.error('[kapi] failed to spawn claude:', err);
69
+ return null;
70
+ }
71
+ claudeProc = claude;
72
+ let buffer = '';
73
+ claude.stdout.on('data', (chunk) => {
74
+ buffer += chunk.toString();
75
+ let newlineIndex;
76
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
77
+ const line = buffer.slice(0, newlineIndex).trim();
78
+ buffer = buffer.slice(newlineIndex + 1);
79
+ if (!line)
80
+ continue;
81
+ let event;
82
+ try {
83
+ event = JSON.parse(line);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ const nextSessionId = sessionIdFromEvent(event);
89
+ if (nextSessionId && nextSessionId !== sessionId) {
90
+ sessionId = nextSessionId;
91
+ console.log(`[kapi] claude session started: ${sessionId}`);
92
+ }
93
+ if (event.type === 'result') {
94
+ // Turn finished — notify the submitter (if any) and start the next queued turn.
95
+ busy = false;
96
+ activeClient?.send('kapi:done');
97
+ activeClient = null;
98
+ processQueue();
99
+ continue;
100
+ }
101
+ const status = describeEvent(event);
102
+ if (status)
103
+ activeClient?.send('kapi:processing', { status });
104
+ }
105
+ });
106
+ claude.stderr.pipe(process.stderr);
107
+ claude.on('error', (err) => {
108
+ console.error('[kapi] claude process error:', err);
109
+ });
110
+ // Writes to stdin can outrace process teardown (e.g. right after a kill()
111
+ // from stop()); without this listener that throws an uncaught EPIPE.
112
+ claude.stdin.on('error', (err) => {
113
+ console.error('[kapi] claude stdin error:', err);
114
+ });
115
+ claude.on('close', () => {
116
+ if (claudeProc === claude)
117
+ claudeProc = null;
118
+ // A queued turn (or the killed-and-stopped one's successor) respawns
119
+ // the process, resuming the same session via --resume.
120
+ busy = false;
121
+ processQueue();
122
+ });
123
+ return claude;
124
+ }
125
+ function processQueue() {
126
+ if (busy || pendingPrompts.length === 0)
127
+ return;
128
+ const claude = ensureClaudeProc();
129
+ if (!claude || !claude.stdin) {
130
+ console.error('[kapi] cannot process comments: claude process unavailable');
131
+ // Nothing will retry this later, so drain the whole backlog now rather
132
+ // than stranding everything behind the first failed prompt.
133
+ const stranded = pendingPrompts.splice(0, pendingPrompts.length);
134
+ for (const { client } of stranded)
135
+ client?.send('kapi:done');
136
+ return;
137
+ }
138
+ const next = pendingPrompts.shift();
139
+ busy = true;
140
+ activeClient = next.client;
141
+ next.client?.send('kapi:processing', { status: 'Starting...' });
142
+ try {
143
+ claude.stdin.write(JSON.stringify({
144
+ type: 'user',
145
+ message: { role: 'user', content: [{ type: 'text', text: next.prompt }] },
146
+ }) + '\n');
147
+ }
148
+ catch (err) {
149
+ console.error('[kapi] failed to write prompt to claude stdin:', err);
150
+ busy = false;
151
+ activeClient = null;
152
+ processQueue();
153
+ }
154
+ }
155
+ export const claudeAgent = {
156
+ start(startCwd) {
157
+ cwd = startCwd;
158
+ // Send the kapi instructions as the session's first turn, before any
159
+ // tab connects — also warms Claude's prompt cache so the user's actual
160
+ // first submission (turn 2) doesn't pay a cold-cache latency hit.
161
+ pendingPrompts.push({ prompt: KAPI_SESSION_PROMPT, client: null });
162
+ processQueue();
163
+ },
164
+ submit(prompt, client) {
165
+ pendingPrompts.push({ prompt, client });
166
+ processQueue();
167
+ },
168
+ stop(client) {
169
+ // Drop anything this client queued but hasn't started.
170
+ for (let i = pendingPrompts.length - 1; i >= 0; i--) {
171
+ if (pendingPrompts[i].client === client)
172
+ pendingPrompts.splice(i, 1);
173
+ }
174
+ if (activeClient === client && claudeProc) {
175
+ console.log('[kapi] stopping claude process');
176
+ claudeProc.kill();
177
+ // close handler respawns the process (resuming the session) so the
178
+ // next queued turn — from any tab — continues normally.
179
+ }
180
+ },
181
+ onClose(client) {
182
+ // A closed tab shouldn't leave orphaned queued work or a stuck queue.
183
+ claudeAgent.stop(client);
184
+ },
185
+ };