codekin 0.6.4 → 0.7.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.
Files changed (86) hide show
  1. package/README.md +8 -5
  2. package/bin/codekin.mjs +69 -6
  3. package/dist/assets/index-CLuQVRRb.css +1 -0
  4. package/dist/assets/index-JVnFWiSw.js +185 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +6 -4
  7. package/server/dist/anthropic-models.d.ts +40 -0
  8. package/server/dist/anthropic-models.js +212 -0
  9. package/server/dist/anthropic-models.js.map +1 -0
  10. package/server/dist/claude-process.d.ts +32 -3
  11. package/server/dist/claude-process.js +129 -9
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/codex-process.d.ts +147 -0
  14. package/server/dist/codex-process.js +741 -0
  15. package/server/dist/codex-process.js.map +1 -0
  16. package/server/dist/coding-process.d.ts +16 -4
  17. package/server/dist/coding-process.js +10 -0
  18. package/server/dist/coding-process.js.map +1 -1
  19. package/server/dist/commit-event-handler.d.ts +14 -1
  20. package/server/dist/commit-event-handler.js +40 -8
  21. package/server/dist/commit-event-handler.js.map +1 -1
  22. package/server/dist/config.d.ts +25 -0
  23. package/server/dist/config.js +42 -0
  24. package/server/dist/config.js.map +1 -1
  25. package/server/dist/opencode-process.d.ts +142 -5
  26. package/server/dist/opencode-process.js +664 -84
  27. package/server/dist/opencode-process.js.map +1 -1
  28. package/server/dist/orchestrator-children.d.ts +94 -7
  29. package/server/dist/orchestrator-children.js +375 -65
  30. package/server/dist/orchestrator-children.js.map +1 -1
  31. package/server/dist/orchestrator-manager.d.ts +10 -0
  32. package/server/dist/orchestrator-manager.js +70 -18
  33. package/server/dist/orchestrator-manager.js.map +1 -1
  34. package/server/dist/orchestrator-monitor.d.ts +7 -1
  35. package/server/dist/orchestrator-monitor.js +62 -19
  36. package/server/dist/orchestrator-monitor.js.map +1 -1
  37. package/server/dist/orchestrator-notify.d.ts +42 -0
  38. package/server/dist/orchestrator-notify.js +42 -0
  39. package/server/dist/orchestrator-notify.js.map +1 -0
  40. package/server/dist/orchestrator-outbox.d.ts +48 -0
  41. package/server/dist/orchestrator-outbox.js +154 -0
  42. package/server/dist/orchestrator-outbox.js.map +1 -0
  43. package/server/dist/orchestrator-session-router.js +43 -1
  44. package/server/dist/orchestrator-session-router.js.map +1 -1
  45. package/server/dist/prompt-router.d.ts +22 -1
  46. package/server/dist/prompt-router.js +94 -11
  47. package/server/dist/prompt-router.js.map +1 -1
  48. package/server/dist/session-archive.js +11 -1
  49. package/server/dist/session-archive.js.map +1 -1
  50. package/server/dist/session-lifecycle.d.ts +1 -0
  51. package/server/dist/session-lifecycle.js +37 -0
  52. package/server/dist/session-lifecycle.js.map +1 -1
  53. package/server/dist/session-manager.d.ts +49 -2
  54. package/server/dist/session-manager.js +221 -33
  55. package/server/dist/session-manager.js.map +1 -1
  56. package/server/dist/session-naming.d.ts +4 -0
  57. package/server/dist/session-naming.js +26 -5
  58. package/server/dist/session-naming.js.map +1 -1
  59. package/server/dist/session-routes.js +42 -2
  60. package/server/dist/session-routes.js.map +1 -1
  61. package/server/dist/stepflow-handler.js +2 -2
  62. package/server/dist/stepflow-handler.js.map +1 -1
  63. package/server/dist/tsconfig.tsbuildinfo +1 -1
  64. package/server/dist/types.d.ts +24 -3
  65. package/server/dist/types.js +1 -9
  66. package/server/dist/types.js.map +1 -1
  67. package/server/dist/upload-routes.d.ts +7 -0
  68. package/server/dist/upload-routes.js +85 -28
  69. package/server/dist/upload-routes.js.map +1 -1
  70. package/server/dist/webhook-handler.js +3 -3
  71. package/server/dist/webhook-handler.js.map +1 -1
  72. package/server/dist/workflow-config.d.ts +2 -2
  73. package/server/dist/workflow-engine.d.ts +20 -0
  74. package/server/dist/workflow-engine.js +52 -15
  75. package/server/dist/workflow-engine.js.map +1 -1
  76. package/server/dist/workflow-loader.d.ts +5 -5
  77. package/server/dist/workflow-loader.js +169 -54
  78. package/server/dist/workflow-loader.js.map +1 -1
  79. package/server/dist/workflow-routes.js +36 -2
  80. package/server/dist/workflow-routes.js.map +1 -1
  81. package/server/dist/ws-message-handler.js +24 -9
  82. package/server/dist/ws-message-handler.js.map +1 -1
  83. package/server/dist/ws-server.js +53 -11
  84. package/server/dist/ws-server.js.map +1 -1
  85. package/dist/assets/index-BRB_Ksyk.js +0 -182
  86. package/dist/assets/index-Q2WSVlHo.css +0 -1
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>Codekin</title>
8
- <script type="module" crossorigin src="/assets/index-BRB_Ksyk.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Q2WSVlHo.css">
8
+ <script type="module" crossorigin src="/assets/index-JVnFWiSw.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CLuQVRRb.css">
10
10
  </head>
11
11
  <body class="bg-neutral-12 text-neutral-2">
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codekin",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "licenseNotes": "dompurify is dual-licensed (MPL-2.0 OR Apache-2.0); both are permissively compatible with MIT for library use. lightningcss (MPL-2.0) is a build-time-only dependency used by TailwindCSS and is not included in distributed artifacts.",
6
6
  "author": "multiplier-labs",
@@ -39,8 +39,10 @@
39
39
  "dependencies": {
40
40
  "better-sqlite3": "^12.9.0",
41
41
  "express": "^5.1.0",
42
+ "file-type": "^22.0.1",
42
43
  "multer": "^2.0.0",
43
- "ws": "^8.18.0"
44
+ "ws": "^8.21.0",
45
+ "yaml": "^2.9.0"
44
46
  },
45
47
  "overrides": {
46
48
  "undici": "^7.24.0",
@@ -69,8 +71,8 @@
69
71
  "globals": "^17.4.0",
70
72
  "highlight.js": "^11.11.1",
71
73
  "jsdom": "^29.0.1",
72
- "marked": "^17.0.4",
73
- "marked-highlight": "^2.2.3",
74
+ "marked": "^18.0.2",
75
+ "marked-highlight": "^2.2.4",
74
76
  "react": "^19.2.0",
75
77
  "react-diff-view": "^3.3.2",
76
78
  "react-dom": "^19.2.0",
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Discovers available Claude models via two strategies:
3
+ *
4
+ * 1. **Anthropic API** — `GET /v1/models` using ANTHROPIC_API_KEY (if set).
5
+ * Returns the full catalog, cached for 1 hour.
6
+ *
7
+ * 2. **CLI alias probing** — Spawns `claude -p --model <alias> "ok"` for each
8
+ * known alias (opus, sonnet, haiku) and reads the resolved model ID from
9
+ * the JSON output's `modelUsage` field. Runs once per day, triggered on
10
+ * first session creation. Works with OAuth/subscription auth (no API key).
11
+ *
12
+ * Falls back to a hardcoded list when neither strategy has completed yet.
13
+ */
14
+ export interface ClaudeModelInfo {
15
+ id: string;
16
+ label: string;
17
+ }
18
+ /** Hardcoded fallback used until dynamic discovery completes.
19
+ * Per https://platform.claude.com/docs/en/about-claude/models/overview */
20
+ export declare const FALLBACK_MODELS: ClaudeModelInfo[];
21
+ /**
22
+ * Synchronously return the best-known default Claude model ID — the first
23
+ * (newest) entry of the discovered list, or the hardcoded fallback's first
24
+ * entry when discovery hasn't completed yet. This matches the model the
25
+ * frontend auto-selects (the [0] of the same list), so new sessions start on
26
+ * it directly instead of letting the CLI pick a stale default and forcing a
27
+ * disruptive model-switch restart that drops the user's first message.
28
+ */
29
+ export declare function getDefaultClaudeModel(): string;
30
+ /**
31
+ * Return available Claude models. Uses cached results when valid.
32
+ * Called by the GET /api/claude/models endpoint.
33
+ */
34
+ export declare function fetchAnthropicModels(): Promise<ClaudeModelInfo[]>;
35
+ /**
36
+ * Trigger a background CLI alias probe if the cache is stale or empty.
37
+ * Call this on first session creation of the day. Non-blocking — returns
38
+ * immediately and updates the cache when the probe finishes.
39
+ */
40
+ export declare function triggerCliProbeIfNeeded(): void;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Discovers available Claude models via two strategies:
3
+ *
4
+ * 1. **Anthropic API** — `GET /v1/models` using ANTHROPIC_API_KEY (if set).
5
+ * Returns the full catalog, cached for 1 hour.
6
+ *
7
+ * 2. **CLI alias probing** — Spawns `claude -p --model <alias> "ok"` for each
8
+ * known alias (opus, sonnet, haiku) and reads the resolved model ID from
9
+ * the JSON output's `modelUsage` field. Runs once per day, triggered on
10
+ * first session creation. Works with OAuth/subscription auth (no API key).
11
+ *
12
+ * Falls back to a hardcoded list when neither strategy has completed yet.
13
+ */
14
+ import { execFile } from 'child_process';
15
+ import { CLAUDE_BINARY } from './config.js';
16
+ /** Hardcoded fallback used until dynamic discovery completes.
17
+ * Per https://platform.claude.com/docs/en/about-claude/models/overview */
18
+ export const FALLBACK_MODELS = [
19
+ { id: 'claude-opus-4-8', label: 'Opus 4.8' },
20
+ { id: 'claude-fable-5', label: 'Fable 5' },
21
+ { id: 'claude-opus-4-7', label: 'Opus 4.7' },
22
+ { id: 'claude-opus-4-6', label: 'Opus 4.6' },
23
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
24
+ { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
25
+ ];
26
+ /**
27
+ * Candidate model IDs to probe via the CLI. We don't probe aliases (opus/sonnet/haiku)
28
+ * because the CLI's alias resolution lags — `opus` still resolves to 4-6 even when
29
+ * 4-8 is the latest. Instead, we probe specific version IDs spanning current and
30
+ * likely-future releases. Failed probes return in ~2.5s at zero cost; successful
31
+ * probes cost ~$0.04 each. Probing in parallel keeps total wall time under 5s.
32
+ */
33
+ const CANDIDATE_MODEL_IDS = [
34
+ // Fable family (5th-generation flagship; GA 2026-06-09). Dateless pinned ID.
35
+ 'claude-fable-5',
36
+ 'claude-fable-6',
37
+ // Opus family (currently 4.6, 4.7, 4.8 are live; probe ahead for new releases)
38
+ 'claude-opus-4-6',
39
+ 'claude-opus-4-7',
40
+ 'claude-opus-4-8',
41
+ 'claude-opus-4-9',
42
+ 'claude-opus-5-0',
43
+ // Sonnet family (currently 4.6 is latest; probe ahead)
44
+ 'claude-sonnet-4-6',
45
+ 'claude-sonnet-4-7',
46
+ 'claude-sonnet-4-8',
47
+ 'claude-sonnet-5-0',
48
+ // Haiku family (currently 4.5 is latest; probe ahead — note dated suffix)
49
+ 'claude-haiku-4-5-20251001',
50
+ 'claude-haiku-4-6',
51
+ 'claude-haiku-4-7',
52
+ 'claude-haiku-5-0',
53
+ ];
54
+ // ---------------------------------------------------------------------------
55
+ // Shared cache
56
+ // ---------------------------------------------------------------------------
57
+ let cache = null;
58
+ const API_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour (API is cheap, refresh often)
59
+ const CLI_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours (CLI probing costs tokens)
60
+ /** In-flight probe promise — prevents duplicate concurrent probes. */
61
+ let probeInFlight = null;
62
+ // ---------------------------------------------------------------------------
63
+ // Label helper
64
+ // ---------------------------------------------------------------------------
65
+ /**
66
+ * Build a human-friendly label from a model ID.
67
+ * e.g. "claude-opus-4-8" → "Opus 4.8"
68
+ * "claude-haiku-4-5-20251001" → "Haiku 4.5"
69
+ * "claude-fable-5" → "Fable 5" (single-version, dateless IDs)
70
+ */
71
+ function labelFromId(id) {
72
+ const rest = id.replace(/^claude-/, '');
73
+ const m = rest.match(/^(\w+?)-(\d+)(?:-(\d+))?/);
74
+ if (m) {
75
+ const family = m[1].charAt(0).toUpperCase() + m[1].slice(1);
76
+ return m[3] ? `${family} ${m[2]}.${m[3]}` : `${family} ${m[2]}`;
77
+ }
78
+ return id;
79
+ }
80
+ async function fetchViaApi() {
81
+ const apiKey = process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_API_KEY;
82
+ if (!apiKey)
83
+ return null;
84
+ const res = await fetch('https://api.anthropic.com/v1/models?limit=100', {
85
+ headers: {
86
+ 'x-api-key': apiKey,
87
+ 'anthropic-version': '2023-06-01',
88
+ },
89
+ signal: AbortSignal.timeout(10_000),
90
+ });
91
+ if (!res.ok)
92
+ return null;
93
+ const data = (await res.json());
94
+ if (!Array.isArray(data.data) || data.data.length === 0)
95
+ return null;
96
+ const models = data.data
97
+ .filter(m => m.id.startsWith('claude-') && !m.id.includes('embed'))
98
+ .sort((a, b) => {
99
+ if (a.created_at && b.created_at)
100
+ return b.created_at.localeCompare(a.created_at);
101
+ return 0;
102
+ })
103
+ .map(m => ({ id: m.id, label: m.display_name || labelFromId(m.id) }));
104
+ return models.length > 0 ? models : null;
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Strategy 2: CLI alias probing
108
+ // ---------------------------------------------------------------------------
109
+ /**
110
+ * Probe a single model ID via the CLI. Returns the model ID if available,
111
+ * null otherwise. Failed probes return in ~2.5s without consuming tokens;
112
+ * successful probes take ~4.5s and cost ~$0.04 (one short turn).
113
+ */
114
+ function probeModel(modelId) {
115
+ return new Promise(resolve => {
116
+ const child = execFile(CLAUDE_BINARY,
117
+ // Note: do NOT pass --bare — it strips the modelUsage field we need.
118
+ ['-p', '--model', modelId, '--output-format', 'json', 'reply with only: ok'], { timeout: 30_000 }, (err, stdout) => {
119
+ if (err) {
120
+ resolve(null);
121
+ return;
122
+ }
123
+ try {
124
+ const result = JSON.parse(stdout);
125
+ if (result.is_error) {
126
+ resolve(null);
127
+ return;
128
+ }
129
+ const id = Object.keys(result.modelUsage ?? {})[0];
130
+ resolve(id || null);
131
+ }
132
+ catch {
133
+ resolve(null);
134
+ }
135
+ });
136
+ child.unref?.();
137
+ });
138
+ }
139
+ /** Probe all candidate model IDs in parallel. */
140
+ async function fetchViaCli() {
141
+ const results = await Promise.all(CANDIDATE_MODEL_IDS.map(probeModel));
142
+ const models = [];
143
+ const seen = new Set();
144
+ for (const id of results) {
145
+ if (id && !seen.has(id)) {
146
+ seen.add(id);
147
+ models.push({ id, label: labelFromId(id) });
148
+ }
149
+ }
150
+ // Sort newest version first within each family (opus > sonnet > haiku ordering preserved by input)
151
+ return models.length > 0 ? models : null;
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Public API
155
+ // ---------------------------------------------------------------------------
156
+ /**
157
+ * Synchronously return the best-known default Claude model ID — the first
158
+ * (newest) entry of the discovered list, or the hardcoded fallback's first
159
+ * entry when discovery hasn't completed yet. This matches the model the
160
+ * frontend auto-selects (the [0] of the same list), so new sessions start on
161
+ * it directly instead of letting the CLI pick a stale default and forcing a
162
+ * disruptive model-switch restart that drops the user's first message.
163
+ */
164
+ export function getDefaultClaudeModel() {
165
+ return (cache?.models[0] ?? FALLBACK_MODELS[0]).id;
166
+ }
167
+ /**
168
+ * Return available Claude models. Uses cached results when valid.
169
+ * Called by the GET /api/claude/models endpoint.
170
+ */
171
+ export async function fetchAnthropicModels() {
172
+ if (cache && Date.now() < cache.expiresAt) {
173
+ return cache.models;
174
+ }
175
+ // Strategy 1: Anthropic API (fast, full catalog)
176
+ try {
177
+ const apiModels = await fetchViaApi();
178
+ if (apiModels) {
179
+ cache = { models: apiModels, expiresAt: Date.now() + API_CACHE_TTL_MS };
180
+ return apiModels;
181
+ }
182
+ }
183
+ catch { /* fall through */ }
184
+ // If CLI probe has already cached results, use those
185
+ if (cache)
186
+ return cache.models;
187
+ return FALLBACK_MODELS;
188
+ }
189
+ /**
190
+ * Trigger a background CLI alias probe if the cache is stale or empty.
191
+ * Call this on first session creation of the day. Non-blocking — returns
192
+ * immediately and updates the cache when the probe finishes.
193
+ */
194
+ export function triggerCliProbeIfNeeded() {
195
+ // Skip if cache is still valid or a probe is already running
196
+ if (cache && Date.now() < cache.expiresAt)
197
+ return;
198
+ if (probeInFlight)
199
+ return;
200
+ // Skip if API key is available (fetchAnthropicModels will use the API instead)
201
+ if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_API_KEY)
202
+ return;
203
+ probeInFlight = fetchViaCli()
204
+ .then(models => {
205
+ if (models) {
206
+ cache = { models, expiresAt: Date.now() + CLI_CACHE_TTL_MS };
207
+ }
208
+ })
209
+ .catch(() => { })
210
+ .finally(() => { probeInFlight = null; });
211
+ }
212
+ //# sourceMappingURL=anthropic-models.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"anthropic-models.js","sourceRoot":"","sources":["../anthropic-models.ts"],"names":[],"mappings":"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"}
@@ -11,7 +11,7 @@
11
11
  * - Turn results and process exit
12
12
  */
13
13
  import { EventEmitter } from 'events';
14
- import type { ClaudeEvent, TaskItem, PromptQuestion, PermissionMode } from './types.js';
14
+ import type { ClaudeEvent, TaskItem, PromptQuestion, PermissionMode, SessionUsage } from './types.js';
15
15
  import { type CodingProcess, type CodingProvider, type ProviderCapabilities } from './coding-process.js';
16
16
  /** Options for constructing a ClaudeProcess. Replaces positional constructor parameters. */
17
17
  export interface ClaudeProcessOptions {
@@ -51,6 +51,8 @@ export interface ClaudeProcessEvents {
51
51
  todo_update: [tasks: TaskItem[]];
52
52
  image: [base64: string, mediaType: string];
53
53
  result: [text: string, isError: boolean];
54
+ usage: [usage: SessionUsage];
55
+ rate_limit: [event: Record<string, unknown>];
54
56
  error: [message: string];
55
57
  exit: [code: number | null, signal: string | null];
56
58
  }
@@ -73,6 +75,9 @@ export declare class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> imp
73
75
  * skip retries when this flag is true.
74
76
  */
75
77
  private _sessionConflict;
78
+ /** Cumulative token counts across all result events in this process's lifetime. */
79
+ private cumulativeInputTokens;
80
+ private cumulativeOutputTokens;
76
81
  /**
77
82
  * Set to true when spawn() itself fails (ENOENT, EACCES, etc.).
78
83
  * Distinguished from "process started but produced no output" — the latter
@@ -90,6 +95,8 @@ export declare class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> imp
90
95
  private tool;
91
96
  private tasks;
92
97
  private taskSeq;
98
+ /** Outbound control requests (server → CLI, e.g. set_permission_mode) awaiting a control_response. */
99
+ private pendingOutboundControl;
93
100
  /** Additional env vars passed to the child process (session ID, port, token). */
94
101
  private extraEnv;
95
102
  /** When true, use `--resume` instead of `--session-id` to resume an existing session. */
@@ -138,13 +145,25 @@ export declare class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> imp
138
145
  * for user interaction, not permissions).
139
146
  */
140
147
  private handleControlRequest;
141
- /** Send a control_response back to the CLI to allow or deny a pending request. */
142
- sendControlResponse(requestId: string, behavior: 'allow' | 'deny', updatedInput?: Record<string, unknown>, message?: string): void;
148
+ /**
149
+ * Send a control_response back to the CLI to allow or deny a pending request.
150
+ * 'allow_always' is treated as 'allow' — the CLI protocol has no persistent
151
+ * grant; persistence is handled by Codekin's ApprovalManager.
152
+ */
153
+ sendControlResponse(requestId: string, behavior: 'allow' | 'deny' | 'allow_always', updatedInput?: Record<string, unknown>, message?: string): void;
143
154
  /**
144
155
  * Update internal task state from TodoWrite/TaskCreate/TaskUpdate tool calls.
145
156
  * Returns true if the task list changed (caller should emit todo_update).
146
157
  */
147
158
  private handleTaskTool;
159
+ /** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
160
+ private syncTaskSeq;
161
+ /**
162
+ * Seed task state from a previous process's last known list (session restore).
163
+ * Without this, a restarted process starts with an empty map and TaskUpdate
164
+ * calls referencing pre-restart task ids would otherwise be lost.
165
+ */
166
+ seedTasks(tasks: TaskItem[]): void;
148
167
  /**
149
168
  * Extract a short summary from extended thinking text.
150
169
  * Tries to grab the first sentence (up to 120 chars), or truncates at a
@@ -153,6 +172,16 @@ export declare class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> imp
153
172
  private extractThinkingSummary;
154
173
  /** Send a user message to the Claude CLI via stdin (stream-json format). */
155
174
  sendMessage(content: string): void;
175
+ /**
176
+ * Change the CLI's permission mode in-place via a stream-json control request,
177
+ * without restarting the process (which would kill in-flight turns and pending
178
+ * approvals). Resolves true on CLI acknowledgement, false on timeout/error/exit —
179
+ * callers should fall back to a process restart on false.
180
+ *
181
+ * 'dangerouslySkipPermissions' is a spawn flag (--dangerously-skip-permissions),
182
+ * not a runtime mode, so it always requires a restart.
183
+ */
184
+ setPermissionMode(mode: PermissionMode): Promise<boolean>;
156
185
  /** Write raw data to stdin (used for control_response messages). */
157
186
  sendRaw(data: string): void;
158
187
  /** Gracefully stop the process (SIGTERM, then SIGKILL after 5s timeout). */
@@ -41,6 +41,9 @@ export class ClaudeProcess extends EventEmitter {
41
41
  * skip retries when this flag is true.
42
42
  */
43
43
  _sessionConflict = false;
44
+ /** Cumulative token counts across all result events in this process's lifetime. */
45
+ cumulativeInputTokens = 0;
46
+ cumulativeOutputTokens = 0;
44
47
  /**
45
48
  * Set to true when spawn() itself fails (ENOENT, EACCES, etc.).
46
49
  * Distinguished from "process started but produced no output" — the latter
@@ -60,6 +63,8 @@ export class ClaudeProcess extends EventEmitter {
60
63
  // Task/todo state: mirrors Claude's internal todo list for the UI
61
64
  tasks = new Map();
62
65
  taskSeq = 0;
66
+ /** Outbound control requests (server → CLI, e.g. set_permission_mode) awaiting a control_response. */
67
+ pendingOutboundControl = new Map;
63
68
  /** Additional env vars passed to the child process (session ID, port, token). */
64
69
  extraEnv;
65
70
  /** When true, use `--resume` instead of `--session-id` to resume an existing session. */
@@ -146,6 +151,10 @@ export class ClaudeProcess extends EventEmitter {
146
151
  // a resume. Uses the full binary path + exact session UUID to avoid
147
152
  // matching unrelated processes.
148
153
  if (this.resume) {
154
+ // Guard against non-UUID values being interpolated into the pkill pattern
155
+ if (!/^[0-9a-f-]{36}$/i.test(this.sessionId)) {
156
+ throw new Error(`[claude-spawn] sessionId is not a valid UUID: ${this.sessionId}`);
157
+ }
149
158
  try {
150
159
  const pattern = `${CLAUDE_BINARY} .*(--resume|--session-id) ${this.sessionId}(\\s|$)`;
151
160
  execFileSync('pkill', ['-f', pattern], { timeout: 2000, stdio: 'ignore' });
@@ -154,7 +163,10 @@ export class ClaudeProcess extends EventEmitter {
154
163
  // pkill exits 1 when no matching process is found — that's the happy path
155
164
  }
156
165
  }
157
- console.log(`[claude-spawn] cwd=${this.workingDir} args=${JSON.stringify(args)}`);
166
+ const redactedArgs = args.map((a, i) => i > 0 && /^[0-9a-f-]{36}$/i.test(a) && ['--session-id', '--resume'].includes(args[i - 1])
167
+ ? '<redacted>'
168
+ : a);
169
+ console.log(`[claude-spawn] cwd=${this.workingDir} args=${JSON.stringify(redactedArgs)}`);
158
170
  this.proc = spawn(CLAUDE_BINARY, args, {
159
171
  cwd: this.workingDir,
160
172
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -204,6 +216,11 @@ export class ClaudeProcess extends EventEmitter {
204
216
  this.rl = null;
205
217
  this.proc = null;
206
218
  this.tasks.clear();
219
+ // Fail any outbound control requests still awaiting a response —
220
+ // the process is gone, so callers should fall back to a restart.
221
+ for (const resolve of this.pendingOutboundControl.values())
222
+ resolve(false);
223
+ this.pendingOutboundControl.clear();
207
224
  this.emit('exit', code, signal);
208
225
  });
209
226
  }
@@ -233,7 +250,7 @@ export class ClaudeProcess extends EventEmitter {
233
250
  console.log(`[event] type=${event.type} subtype=${subtype || '-'}`);
234
251
  }
235
252
  // Log all event types we DON'T handle to catch unknown protocol messages
236
- if (!['system', 'stream_event', 'assistant', 'user', 'result', 'control_request'].includes(event.type)) {
253
+ if (!['system', 'stream_event', 'assistant', 'user', 'result', 'control_request', 'control_response', 'rate_limit_event'].includes(event.type)) {
237
254
  console.log(`[event-unhandled] type=${event.type} data=${JSON.stringify(event).slice(0, 300)}`);
238
255
  }
239
256
  switch (event.type) {
@@ -255,6 +272,20 @@ export class ClaudeProcess extends EventEmitter {
255
272
  break;
256
273
  case 'result': {
257
274
  const resultEvent = event;
275
+ // Surface cumulative token/cost usage. The CLI's result event carries
276
+ // per-turn usage plus a cumulative total_cost_usd for the session.
277
+ const u = resultEvent.usage;
278
+ if (u) {
279
+ this.cumulativeInputTokens += (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
280
+ this.cumulativeOutputTokens += u.output_tokens ?? 0;
281
+ }
282
+ if (u || typeof resultEvent.total_cost_usd === 'number') {
283
+ this.emit('usage', {
284
+ inputTokens: this.cumulativeInputTokens,
285
+ outputTokens: this.cumulativeOutputTokens,
286
+ ...(typeof resultEvent.total_cost_usd === 'number' ? { costUsd: resultEvent.total_cost_usd } : {}),
287
+ });
288
+ }
258
289
  this.emit('result', resultEvent.result || '', resultEvent.is_error || false);
259
290
  break;
260
291
  }
@@ -265,6 +296,21 @@ export class ClaudeProcess extends EventEmitter {
265
296
  this.handleControlRequest(ctrlEvent);
266
297
  break;
267
298
  }
299
+ case 'control_response': {
300
+ // Response to an outbound control_request we sent (e.g. set_permission_mode).
301
+ const resp = event.response;
302
+ if (resp?.request_id) {
303
+ const resolve = this.pendingOutboundControl.get(resp.request_id);
304
+ if (resolve) {
305
+ this.pendingOutboundControl.delete(resp.request_id);
306
+ resolve(resp.subtype === 'success');
307
+ }
308
+ }
309
+ break;
310
+ }
311
+ case 'rate_limit_event':
312
+ this.emit('rate_limit', event);
313
+ break;
268
314
  }
269
315
  }
270
316
  /**
@@ -345,7 +391,9 @@ export class ClaudeProcess extends EventEmitter {
345
391
  if (this.handleTaskTool(this.tool.name, parsed)) {
346
392
  if (TOOL_DEBUG)
347
393
  console.log('[task-debug] emitting todo_update, tasks:', this.tasks.size);
348
- this.emit('todo_update', Array.from(this.tasks.values()));
394
+ // Copy items so later in-place TaskUpdate mutations don't alias into
395
+ // previously broadcast/history-stored snapshots.
396
+ this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })));
349
397
  }
350
398
  }
351
399
  catch (err) {
@@ -469,7 +517,11 @@ export class ClaudeProcess extends EventEmitter {
469
517
  this.emit('control_request', request_id, toolName, toolInput);
470
518
  }
471
519
  }
472
- /** Send a control_response back to the CLI to allow or deny a pending request. */
520
+ /**
521
+ * Send a control_response back to the CLI to allow or deny a pending request.
522
+ * 'allow_always' is treated as 'allow' — the CLI protocol has no persistent
523
+ * grant; persistence is handled by Codekin's ApprovalManager.
524
+ */
473
525
  sendControlResponse(requestId, behavior, updatedInput, message) {
474
526
  // The CLI expects a nested format: { type, response: { subtype, request_id, response: { behavior, ... } } }
475
527
  // Inner response schema:
@@ -502,15 +554,18 @@ export class ClaudeProcess extends EventEmitter {
502
554
  * Returns true if the task list changed (caller should emit todo_update).
503
555
  */
504
556
  handleTaskTool(toolName, input) {
505
- // TodoWrite sends the entire list at once
557
+ // TodoWrite sends the entire list at once.
558
+ // Note: taskSeq is intentionally NOT reset — keeping ids monotonic across
559
+ // list generations lets the frontend detect a brand-new list by id and
560
+ // avoids id collisions with later TaskCreate/TaskUpdate calls.
506
561
  if (toolName === 'TodoWrite') {
507
562
  const todos = input.todos;
508
563
  if (!Array.isArray(todos))
509
564
  return false;
510
565
  this.tasks.clear();
511
- this.taskSeq = 0;
512
566
  for (const item of todos) {
513
567
  const id = String(item.id || ++this.taskSeq);
568
+ this.syncTaskSeq(id);
514
569
  const status = item.status;
515
570
  if (status !== 'pending' && status !== 'in_progress' && status !== 'completed')
516
571
  continue;
@@ -525,7 +580,8 @@ export class ClaudeProcess extends EventEmitter {
525
580
  }
526
581
  // TaskCreate/TaskUpdate are the newer tool names
527
582
  if (toolName === 'TaskCreate') {
528
- const id = String(++this.taskSeq);
583
+ const id = String(input.taskId || input.id || ++this.taskSeq);
584
+ this.syncTaskSeq(id);
529
585
  this.tasks.set(id, {
530
586
  id,
531
587
  subject: String(input.subject || ''),
@@ -536,9 +592,19 @@ export class ClaudeProcess extends EventEmitter {
536
592
  }
537
593
  if (toolName === 'TaskUpdate') {
538
594
  const id = String(input.taskId || '');
539
- const task = this.tasks.get(id);
540
- if (!task)
595
+ if (!id)
541
596
  return false;
597
+ let task = this.tasks.get(id);
598
+ if (!task) {
599
+ // Unknown id — our in-memory map can diverge from the CLI's real task
600
+ // list (e.g. after a process restart mid-session). Upsert instead of
601
+ // dropping the update, otherwise the UI shows a stale list forever.
602
+ if (input.status === 'deleted')
603
+ return false;
604
+ task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' };
605
+ this.tasks.set(id, task);
606
+ this.syncTaskSeq(id);
607
+ }
542
608
  const status = input.status;
543
609
  if (status === 'deleted') {
544
610
  this.tasks.delete(id);
@@ -555,6 +621,24 @@ export class ClaudeProcess extends EventEmitter {
555
621
  }
556
622
  return false;
557
623
  }
624
+ /** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
625
+ syncTaskSeq(id) {
626
+ const n = Number(id);
627
+ if (Number.isInteger(n) && n > this.taskSeq)
628
+ this.taskSeq = n;
629
+ }
630
+ /**
631
+ * Seed task state from a previous process's last known list (session restore).
632
+ * Without this, a restarted process starts with an empty map and TaskUpdate
633
+ * calls referencing pre-restart task ids would otherwise be lost.
634
+ */
635
+ seedTasks(tasks) {
636
+ this.tasks.clear();
637
+ for (const t of tasks) {
638
+ this.tasks.set(t.id, { ...t });
639
+ this.syncTaskSeq(t.id);
640
+ }
641
+ }
558
642
  /**
559
643
  * Extract a short summary from extended thinking text.
560
644
  * Tries to grab the first sentence (up to 120 chars), or truncates at a
@@ -594,6 +678,42 @@ export class ClaudeProcess extends EventEmitter {
594
678
  this.proc.stdin.once('drain', () => { });
595
679
  }
596
680
  }
681
+ /**
682
+ * Change the CLI's permission mode in-place via a stream-json control request,
683
+ * without restarting the process (which would kill in-flight turns and pending
684
+ * approvals). Resolves true on CLI acknowledgement, false on timeout/error/exit —
685
+ * callers should fall back to a process restart on false.
686
+ *
687
+ * 'dangerouslySkipPermissions' is a spawn flag (--dangerously-skip-permissions),
688
+ * not a runtime mode, so it always requires a restart.
689
+ */
690
+ setPermissionMode(mode) {
691
+ if (mode === 'dangerouslySkipPermissions' || this.permissionMode === 'dangerouslySkipPermissions') {
692
+ return Promise.resolve(false);
693
+ }
694
+ if (!this.proc?.stdin?.writable)
695
+ return Promise.resolve(false);
696
+ const requestId = randomUUID();
697
+ return new Promise((resolve) => {
698
+ const timer = setTimeout(() => {
699
+ this.pendingOutboundControl.delete(requestId);
700
+ console.warn(`[set-permission-mode] timed out waiting for CLI ack (mode=${mode})`);
701
+ resolve(false);
702
+ }, 5000);
703
+ this.pendingOutboundControl.set(requestId, (ok) => {
704
+ clearTimeout(timer);
705
+ if (ok)
706
+ this.permissionMode = mode;
707
+ console.log(`[set-permission-mode] CLI ${ok ? 'acknowledged' : 'rejected'} mode=${mode}`);
708
+ resolve(ok);
709
+ });
710
+ this.sendRaw(JSON.stringify({
711
+ type: 'control_request',
712
+ request_id: requestId,
713
+ request: { subtype: 'set_permission_mode', mode },
714
+ }));
715
+ });
716
+ }
597
717
  /** Write raw data to stdin (used for control_response messages). */
598
718
  sendRaw(data) {
599
719
  if (!this.proc?.stdin?.writable)