Connects Claude Code sessions in Para Code terminals to Para Code and Para Code Mobile (conversation, live text, questions, approvals, sending, slash commands).

Para Code は、AI コーディングエージェント(Claude Code・Codex など)と一緒に開発することを前提に作られたエディタです。Visual Studio Code を fork し、複数リポジトリの並行作業・常駐ターミナル・エージェントセッション管理・内蔵ブラウザ連携・SSH リモート開発・モバイルからの遠隔操作といった機能を継ぎ足してあります。
このリポジトリは microsoft/vscode を fork した独自エディタです。エディタ本体の土台は本家 VS Code のままで、今後も定期的に upstream の新しいリリースを取り込み続けます。そのうえに、複数のエージェントを並行して走らせながら開発する働き方に合わせた機能を追加しています。
<img alt="Para Code" src=".github/readme/screenshot.png">
各バージョンで実際に何が変わったかは、アプリ内の歯車メニュー →「更新履歴」から確認できます。
Para Code は本家 VS Code のソースをベースにしているため、ビルド・デバッグの基本的な流れは How to Contribute や Coding Guidelines がそのまま参考になります。そのうえで、この fork 特有の実装ルール(新機能の置き場所・既存ファイルへのパッチ方針・upstream 取り込み手順など)は CLAUDE.md と NOTES.md にまとめてあるので、変更を加える前に一読してください。
Para Code は microsoft/vscode を upstream として fetch し続けている fork です。エディタのコア機能(言語サポート、デバッグ、拡張機能 API など)は本家の開発にそのまま追従し、Para Code 独自の機能はなるべく新規ファイル・薄いフックポイントだけで完結させる方針で実装しています。本家の関連プロジェクト一覧は Related Projects を参照してください。
extensions フォルダには、多言語対応の文法・スニペットなどを提供する組み込み拡張機能が含まれます(本家由来)。加えて Para Code では、Open VSX に未公開のいくつかのサードパーティ拡張機能を VSIX として同梱し、起動時に自動インストールする仕組みを備えています。拡張機能の取得元は Open VSX Registry です。
このリポジトリには Visual Studio Code Dev Containers / GitHub Codespaces 用の開発コンテナ設定が含まれています。
フルビルドには最低でも 4 コア・6 GB の RAM(推奨 8 GB) が必要です。詳細は development container README を参照してください。
このプロジェクトに参加するすべての人は、互いに敬意を持って接してください。攻撃的な言動やハラスメントは認められません。問題があれば Issue で報告してください。
Copyright (c) 2015 - present Microsoft Corporation. Para Code による追加・変更部分も含め、MIT ライセンスの下で公開されています。
hooks/register.ts 855 lines1/*---------------------------------------------------------------------------------------------
2 * Copyright (c) Microsoft Corporation. All rights reserved.
3 * Licensed under the MIT License. See License.txt in the project root for license information.
4 *--------------------------------------------------------------------------------------------*/
5// allow-any-unicode-comment-file
6// PARA-CODE: fork-owned file (Para Code) — not present in upstream microsoft/vscode. See CLAUDE.md.
7
8// The Para Code mod for Claude Code (Claude Mods, function hooks).
9//
10// Para Code loads this folder into the Claude Code sessions it starts in its own terminals
11// (CLAUDE_CODE_PLUGIN_DIRS, see src/vs/paradis/contrib/claudeMod). It talks to the Para Code
12// shared process over the same loopback port and pane token the settings hooks use
13// (PARA_CODE_MCP_PORT_FILE / PARA_CODE_TERMINAL_PANE_ID), under /claude-mod/v1/<op>.
14//
15// It never changes what Claude Code does on its own: every hook passes through, and the only
16// answers it gives are the ones the person gave on Para Code Mobile (a question's answers, an
17// approval). The settings hooks, the transcript and the key injection keep working beside it,
18// so a session where this mod is not loaded (or Para Code cannot be reached) behaves as before.
19//
20// Rules for this file:
21// - Never wait on a promise of our own inside a hook: only `next` and `$` calls, whose time the
22// 10 second budget does not count. Background work runs from the session.start engine.
23// - Every request that fails is dropped quietly: Para Code may be restarting or gone.
24
25import type { EngineInterface, Register } from 'claude-code';
26
27type Engine = EngineInterface;
28type Json = Record<string, unknown>;
29
30const MOD_PROTOCOL = '1';
31const MOD_VERSION = '1.3.0';
32/**
33 * What this mod can do beyond the first version, sent with every wait for commands (Para Code may have
34 * restarted since hello): `commands.list` answers `commandList` with `$.command.list()`, `command.run` runs a
35 * slash command for Para Code Mobile with `$.command.run`, `prompt.dialog` answers `dialogCheck` (whether a
36 * screen such as `/config` holds the keys).
37 */
38const MOD_FEATURES = ['commands.list', 'command.run', 'prompt.dialog'];
39/** How long a slash command may take before Para Code is told it was taken (a panel holds `$.command.run` open). */
40const COMMAND_RUN_EARLY_MS = 1_500;
41/**
42 * Commands sent back for `commandList` at most (Claude Code lists hundreds with many skills). The built-ins come
43 * last in the typeahead's order, so they keep their places first and the rest fill what is left, in order.
44 */
45const MAX_LISTED_COMMANDS = 2_000;
46/** How long the port read from the port file is trusted before it is read again. */
47const ENDPOINT_TTL_MS = 30_000;
48/** Text chunks of a response are sent at most this often. */
49const STEP_FLUSH_MS = 150;
50/** Events waiting to be sent; beyond this new ones are dropped (Para Code is not answering). */
51const MAX_QUEUED_EVENTS = 512;
52/** Rows larger than this are left to the transcript reader. */
53const MAX_ROW_CHARS = 256 * 1024;
54/** One wait asks again at most this many times (each wait is held by Para Code for about 25 s). */
55const MAX_WAIT_ROUNDS = 1_000;
56/** Tool calls remembered from tool.check to pair with the PermissionRequest that follows. */
57const MAX_REMEMBERED_ASKS = 64;
58
59interface IEndpoint {
60 readonly url: string;
61 readonly token: string;
62 readonly at: number;
63}
64
65interface IReply {
66 readonly status: number;
67 readonly json: Json | undefined;
68}
69
70let endpoint: IEndpoint | undefined;
71/** `$.http.fetch` was refused (an administrator's policy) while curl reached Para Code. */
72let useCurl = false;
73/** This module is connected to an interactive session in a Para Code terminal. */
74let active = false;
75let mainBusy = false;
76let pumpGeneration = 0;
77/** Events waiting to be sent, each with the session it happened in (taken when it happened, see emit). */
78let queue: { readonly event: Json; readonly session: Promise<string | undefined> }[] = [];
79let flushing = false;
80/** Approvals waiting for Para Code Mobile: Para Code's id -> the call they belong to. */
81const pendingPermissions = new Map<string, { readonly toolUseId: string | undefined; readonly toolName: string }>();
82/** tool.check answered `ask`: `tool\0input` -> tool_use_ids, oldest first. */
83const askedCalls = new Map<string, string[]>();
84/**
85 * A prompt from Para Code Mobile is being submitted. Only one at a time: another one handed over meanwhile is
86 * refused at once (ack ok: false, reason: 'busy') and Para Code sends it with keys once the first one is
87 * taken, so this mod never sends it. Every other refusal carries a reason (`stale`: the session moved on,
88 * `panel-open`: a screen such as /config holds the keys, `refused`), and Para Code then answers the phone instead
89 * of typing the text.
90 */
91let submitting = false;
92/** Counts the sends, so one that finishes after a new session started does not clear the flag of a later send. */
93let submitGeneration = 0;
94/** A prompt submitted for Para Code, waiting for its row to learn the row's uuid. */
95let submitWatch: { readonly id: string; readonly text: string; uuid?: string } | undefined;
96
97function str(value: unknown): string | undefined {
98 return typeof value === 'string' && value.length > 0 ? value : undefined;
99}
100
101function rec(value: unknown): Json | undefined {
102 return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Json : undefined;
103}
104
105function stableJson(value: unknown): string {
106 if (Array.isArray(value)) {
107 return `[${value.map(stableJson).join(',')}]`;
108 }
109 const record = rec(value);
110 if (record !== undefined) {
111 return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(',')}}`;
112 }
113 return JSON.stringify(value) ?? 'null';
114}
115
116function curlQuote(value: string): string {
117 return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r/g, '\\r').replace(/\n/g, '\\n')}"`;
118}
119
120async function resolveEndpoint($: Engine, refresh = false): Promise<IEndpoint | undefined> {
121 const now = Date.now();
122 if (!refresh && endpoint !== undefined && now - endpoint.at < ENDPOINT_TTL_MS) {
123 return endpoint;
124 }
125 const token = await $.env.get('PARA_CODE_TERMINAL_PANE_ID');
126 const portFile = await $.env.get('PARA_CODE_MCP_PORT_FILE');
127 if (token === undefined || token.length === 0 || portFile === undefined || portFile.length === 0) {
128 endpoint = undefined;
129 return undefined;
130 }
131 let port: unknown;
132 try {
133 port = rec(JSON.parse(String(await $.fs.read(portFile))))?.port;
134 } catch {
135 endpoint = undefined;
136 return undefined;
137 }
138 if (typeof port !== 'number' || !Number.isInteger(port) || port <= 0 || port > 65_535) {
139 endpoint = undefined;
140 return undefined;
141 }
142 endpoint = { url: `http://127.0.0.1:${port}/claude-mod/v1`, token, at: now };
143 return endpoint;
144}
145
146function parseReply(status: number, text: string): IReply {
147 let json: Json | undefined;
148 try {
149 json = rec(JSON.parse(text));
150 } catch {
151 json = undefined;
152 }
153 return { status, json };
154}
155
156async function viaFetch($: Engine, target: IEndpoint, op: string, body: string): Promise<IReply> {
157 const response = await $.http.fetch(`${target.url}/${op}`, {
158 method: 'POST',
159 headers: {
160 'authorization': `Bearer ${target.token}`,
161 'content-type': 'application/json',
162 'x-para-code-mod': MOD_PROTOCOL,
163 },
164 body,
165 });
166 return parseReply(response.status, response.text);
167}
168
169/** The same request through curl: the token and the body go on stdin, never on the command line. */
170async function viaCurl($: Engine, target: IEndpoint, op: string, body: string): Promise<IReply> {
171 const config = [
172 `url = ${curlQuote(`${target.url}/${op}`)}`,
173 'request = "POST"',
174 `header = ${curlQuote(`Authorization: Bearer ${target.token}`)}`,
175 'header = "Content-Type: application/json"',
176 `header = ${curlQuote(`X-Para-Code-Mod: ${MOD_PROTOCOL}`)}`,
177 `data-binary = ${curlQuote(body)}`,
178 'silent',
179 'max-time = 60',
180 'write-out = "\\n%{http_code}"',
181 '',
182 ].join('\n');
183 const result = await $.process.run(['curl', '--config', '-'], { stdin: config, timeoutMs: 70_000 });
184 const newline = result.stdout.lastIndexOf('\n');
185 const status = Number.parseInt(result.stdout.slice(newline + 1), 10);
186 if (result.exitCode !== 0 || !Number.isInteger(status) || status === 0) {
187 throw new Error('curl did not reach Para Code');
188 }
189 return parseReply(status, result.stdout.slice(0, Math.max(0, newline)));
190}
191
192async function request($: Engine, op: string, payload: Json): Promise<IReply | undefined> {
193 const target = await resolveEndpoint($);
194 if (target === undefined) {
195 return undefined;
196 }
197 const body = JSON.stringify(payload);
198 if (useCurl) {
199 try {
200 return await viaCurl($, target, op, body);
201 } catch {
202 endpoint = undefined;
203 return undefined;
204 }
205 }
206 try {
207 return await viaFetch($, target, op, body);
208 } catch {
209 endpoint = undefined;
210 }
211 // The fetch failed. Para Code may have restarted on another port: read the port file again and
212 // fetch once more. Only when that fetch fails too while curl gets through was the fetch refused
213 // (an administrator's policy, not Para Code being away): keep using curl from here on.
214 const retry = await resolveEndpoint($, true);
215 if (retry === undefined) {
216 return undefined;
217 }
218 try {
219 return await viaFetch($, retry, op, body);
220 } catch {
221 // fall through to curl
222 }
223 try {
224 const reply = await viaCurl($, retry, op, body);
225 useCurl = true;
226 return reply;
227 } catch {
228 endpoint = undefined;
229 return undefined;
230 }
231}
232
233/**
234 * Queues an event for Para Code; sent in order and in batches. The session is taken when the event
235 * happens (a /clear starts a new one while older events may still be waiting to be sent).
236 */
237function emit($: Engine, event: Json, sessionId?: string): void {
238 if (!active || queue.length >= MAX_QUEUED_EVENTS) {
239 return;
240 }
241 const session = sessionId !== undefined ? Promise.resolve(sessionId) : $.session.id().then(value => value, () => undefined);
242 queue.push({ event: { ...event, at: Date.now() }, session });
243 if (!flushing) {
244 flushing = true;
245 void flush($);
246 }
247}
248
249async function flush($: Engine): Promise<void> {
250 try {
251 while (queue.length > 0) {
252 const waiting = queue;
253 queue = [];
254 // One request per run of events from the same session, in the order they happened
255 let batch: Json[] = [];
256 let batchSession: string | undefined;
257 for (const item of waiting) {
258 const sessionId = await item.session;
259 if (sessionId === undefined) {
260 continue;
261 }
262 if (batchSession !== undefined && sessionId !== batchSession && batch.length > 0) {
263 await request($, 'event', { sessionId: batchSession, events: batch });
264 batch = [];
265 }
266 batchSession = sessionId;
267 batch.push(item.event);
268 }
269 if (batchSession !== undefined && batch.length > 0) {
270 await request($, 'event', { sessionId: batchSession, events: batch });
271 }
272 }
273 } catch {
274 // Dropped: Para Code reads the transcript and the settings hooks as before.
275 } finally {
276 flushing = false;
277 if (queue.length > 0) {
278 flushing = true;
279 void flush($);
280 }
281 }
282}
283
284/** Asks Para Code until the item `id` is answered, settled or expired; undefined when Para Code is away. */
285async function waitFor($: Engine, sessionId: string, id: string, stop: () => boolean): Promise<Json | undefined> {
286 for (let round = 0; round < MAX_WAIT_ROUNDS && !stop(); round++) {
287 const reply = await request($, 'wait', { sessionId, id });
288 if (reply === undefined || reply.status !== 200 || reply.json === undefined) {
289 return undefined;
290 }
291 if (reply.json.state !== 'pending') {
292 return reply.json;
293 }
294 }
295 return undefined;
296}
297
298function settle($: Engine, sessionId: string, ids: readonly string[]): void {
299 if (ids.length > 0) {
300 void request($, 'settle', { sessionId, ids }).catch(() => undefined);
301 }
302}
303
304/** The approvals this tool call answers (the call only starts once its approval was given). */
305function settleForCall($: Engine, sessionId: string, toolUseId: string | undefined, toolName: string): void {
306 const ids: string[] = [];
307 for (const [id, call] of pendingPermissions) {
308 if ((call.toolUseId !== undefined && call.toolUseId === toolUseId) || (call.toolUseId === undefined && call.toolName === toolName)) {
309 ids.push(id);
310 pendingPermissions.delete(id);
311 }
312 }
313 settle($, sessionId, ids);
314}
315
316function rememberAsk(tool: string, input: unknown, toolUseId: string): void {
317 const key = `${tool}\0${stableJson(input)}`;
318 const ids = askedCalls.get(key) ?? [];
319 ids.push(toolUseId);
320 askedCalls.delete(key);
321 askedCalls.set(key, ids.slice(-8));
322 while (askedCalls.size > MAX_REMEMBERED_ASKS) {
323 const oldest = askedCalls.keys().next();
324 if (oldest.done === true) {
325 break;
326 }
327 askedCalls.delete(oldest.value);
328 }
329}
330
331/** The tool_use_id of the call a PermissionRequest is about (it does not carry one itself). */
332function takeAskedCall(tool: string, input: unknown): string | undefined {
333 const key = `${tool}\0${stableJson(input)}`;
334 const exact = askedCalls.get(key);
335 if (exact !== undefined && exact.length > 0) {
336 const id = exact.shift();
337 if (exact.length === 0) {
338 askedCalls.delete(key);
339 }
340 return id;
341 }
342 // The input may be spelled differently between the two events: accept a single open call of the tool.
343 const sameTool = [...askedCalls.entries()].filter(([candidate, ids]) => candidate.startsWith(`${tool}\0`) && ids.length > 0);
344 const only = sameTool.length === 1 ? sameTool[0] : undefined;
345 if (only !== undefined && only[1].length === 1) {
346 askedCalls.delete(only[0]);
347 return only[1][0];
348 }
349 return undefined;
350}
351
352function contentBlocks(message: unknown): Json[] {
353 const content = rec(message)?.content;
354 return Array.isArray(content) ? content.map(rec).filter((block): block is Json => block !== undefined) : [];
355}
356
357function textOf(blocks: readonly Json[]): string {
358 return blocks.filter(block => block.type === 'text').map(block => str(block.text) ?? '').join('\n');
359}
360
361function observeRow($: Engine, e: { readonly message: unknown; readonly door: string; readonly origin: unknown; readonly uuid: string; readonly agentId?: string }): void {
362 const blocks = contentBlocks(e.message);
363 const results = blocks.filter(block => block.type === 'tool_result');
364 if (results.length > 0) {
365 const ids = results.map(block => str(block.tool_use_id)).filter((id): id is string => id !== undefined);
366 const errorIds = results.filter(block => block.is_error === true).map(block => str(block.tool_use_id)).filter((id): id is string => id !== undefined);
367 emit($, { type: 'tool-results', ids, errorIds, ...(e.agentId !== undefined ? { agentId: e.agentId } : {}) });
368 }
369 const originKind = str(rec(e.origin)?.kind);
370 if (e.agentId === undefined && e.door === 'prompt' && originKind === 'plugin' && submitWatch !== undefined && submitWatch.uuid === undefined
371 && textOf(blocks).trim() === submitWatch.text.trim()) {
372 submitWatch.uuid = e.uuid;
373 }
374 // Only the main conversation's prompts and responses: the transcript reader keeps the rest
375 // (tool results carry structured records, attachments and media are read from the file).
376 if (e.agentId !== undefined || (e.door !== 'prompt' && e.door !== 'response')) {
377 return;
378 }
379 if (blocks.some(block => block.type === 'image' || block.type === 'document')) {
380 return;
381 }
382 const message = rec(e.message) ?? {};
383 const content = typeof message.content === 'string'
384 ? message.content
385 : blocks.map(block => block.type === 'thinking' || block.type === 'redacted_thinking' ? { type: block.type, thinking: block.thinking } : block);
386 const row: Json = {
387 type: 'row', uuid: e.uuid, door: e.door,
388 ...(originKind !== undefined ? { origin: originKind } : {}),
389 message: {
390 type: message.type,
391 ...(message.role !== undefined ? { role: message.role } : {}),
392 ...(message.isMeta === true ? { isMeta: true } : {}),
393 ...(message.name !== undefined ? { name: message.name } : {}),
394 content,
395 },
396 };
397 if (JSON.stringify(row).length <= MAX_ROW_CHARS) {
398 emit($, row);
399 }
400}
401
402/**
403 * Stops a background task (a shell started with run_in_background) for Para Code Mobile. TaskStop asks no
404 * permission and the call leaves nothing in the transcript, so the ack is how Para Code learns the outcome.
405 */
406async function stopTask($: Engine, sessionId: string, id: string, taskId: string): Promise<void> {
407 let ok = false;
408 let message: string | undefined;
409 try {
410 const reply = await $.tool.call({ tool: 'TaskStop', task_id: taskId }) as unknown as Json;
411 const deny = str(reply.deny);
412 ok = deny === undefined && reply.isError !== true && rec(reply.result) !== undefined;
413 message = deny ?? str(rec(reply.result)?.message) ?? str(reply.text);
414 } catch (error) {
415 message = error instanceof Error ? error.message : undefined;
416 }
417 await request($, 'ack', { sessionId, id, ok, ...(message !== undefined ? { message: message.slice(0, 500) } : {}) });
418}
419
420/** The slash commands the person can run now, for Para Code's list on Para Code Mobile (in the typeahead's order). */
421async function listCommands($: Engine, sessionId: string, id: string): Promise<void> {
422 try {
423 const listed = await $.command.list();
424 const builtIns = listed.filter(command => command.source === 'builtin').length;
425 let others = Math.max(0, MAX_LISTED_COMMANDS - builtIns);
426 const kept = listed.length <= MAX_LISTED_COMMANDS ? listed : listed.filter(command => command.source === 'builtin' || others-- > 0);
427 const commands = kept.slice(0, MAX_LISTED_COMMANDS).map(command => ({
428 name: command.name,
429 description: typeof command.description === 'string' ? command.description.slice(0, 300) : '',
430 source: command.source,
431 ...(typeof command.plugin === 'string' ? { plugin: command.plugin.slice(0, 100) } : {}),
432 }));
433 await request($, 'ack', { sessionId, id, ok: true, commands });
434 } catch (error) {
435 await request($, 'ack', { sessionId, id, ok: false, ...(error instanceof Error ? { message: error.message.slice(0, 500) } : {}) });
436 }
437}
438
439/**
440 * Whether a screen holds the keys (`/config`, `/rewind`, `/model`'s picker, and also an approval or a question):
441 * an empty append to the prompt box is refused with `dialog` then, and leaves the draft as it is otherwise.
442 * Telling an approval or a question apart is Para Code's: it asks only while it sees none waiting.
443 */
444async function panelOpen($: Engine): Promise<boolean> {
445 try {
446 const filled = await $.prompt.fill({ text: '', mode: 'append' });
447 return !filled.isFilled && filled.refusal === 'dialog';
448 } catch {
449 return false;
450 }
451}
452
453async function checkDialog($: Engine, sessionId: string, id: string): Promise<void> {
454 await request($, 'ack', { sessionId, id, ok: true, dialog: await panelOpen($) });
455}
456
457function errorText(error: unknown): string | undefined {
458 return error instanceof Error ? error.message.slice(0, 500) : typeof error === 'string' ? error.slice(0, 500) : undefined;
459}
460
461/**
462 * Runs a slash command sent from Para Code Mobile (`$.prompt.submit` refuses a text that begins with `/`).
463 * An unknown name is refused at once, and that refusal goes back to the phone. A command that opens a panel
464 * holds `$.command.run` open until the panel closes: past {@link COMMAND_RUN_EARLY_MS} Para Code is told it
465 * was taken, and the outcome follows in a second ack.
466 */
467async function runSlashCommand($: Engine, sessionId: string, id: string, name: string, args: string, generation: number): Promise<void> {
468 if (name.startsWith('__')) {
469 // Claude Code's own internal commands are not run for the phone.
470 await request($, 'ack', { sessionId, id, ok: false, reason: 'refused', message: `/${name} is internal to Claude Code` });
471 return;
472 }
473 if (submitting) {
474 await request($, 'ack', { sessionId, id, ok: false, reason: 'busy' });
475 return;
476 }
477 submitting = true;
478 const mine = ++submitGeneration;
479 try {
480 if (generation !== pumpGeneration || !active || await $.session.id() !== sessionId) {
481 await request($, 'ack', { sessionId, id, ok: false, reason: 'stale' });
482 return;
483 }
484 if (await panelOpen($)) {
485 // A screen holds the keys: the command would only queue behind it, and Para Code must not type either.
486 await request($, 'ack', { sessionId, id, ok: false, reason: 'panel-open' });
487 return;
488 }
489 const running = $.command.run({ command: name, args }).then(
490 () => ({ ok: true }),
491 (error: unknown) => ({ ok: false, reason: 'refused', ...(errorText(error) !== undefined ? { message: errorText(error) } : {}) }),
492 );
493 const early = await Promise.race([
494 running,
495 new Promise<undefined>(resolve => $.clock.after(COMMAND_RUN_EARLY_MS, () => resolve(undefined))),
496 ]);
497 if (early !== undefined) {
498 await request($, 'ack', { sessionId, id, ...early });
499 return;
500 }
501 // Still running (a screen it opened, or a long command such as /compact): tell Para Code it was taken, then how it ended.
502 await request($, 'ack', { sessionId, id, received: true });
503 await request($, 'ack', { sessionId, id, ...await running });
504 } finally {
505 if (submitGeneration === mine) {
506 submitting = false;
507 }
508 }
509}
510
511/**
512 * The commands Para Code hands over: prompts and slash commands sent from Para Code Mobile while the session
513 * is idle, background tasks to stop, and the list of slash commands.
514 */
515async function runCommand($: Engine, sessionId: string, command: Json, generation: number): Promise<void> {
516 const id = str(command.id);
517 if (id !== undefined && command.kind === 'taskStop') {
518 const taskId = str(command.taskId);
519 if (taskId !== undefined && /^[A-Za-z0-9_-]{1,64}$/.test(taskId)) {
520 await stopTask($, sessionId, id, taskId);
521 }
522 return;
523 }
524 if (id !== undefined && command.kind === 'dialogCheck') {
525 await checkDialog($, sessionId, id);
526 return;
527 }
528 if (id !== undefined && command.kind === 'commandList') {
529 await listCommands($, sessionId, id);
530 return;
531 }
532 if (id !== undefined && command.kind === 'commandRun') {
533 const name = str(command.command);
534 const args = typeof command.args === 'string' ? command.args : '';
535 if (name !== undefined) {
536 await runSlashCommand($, sessionId, id, name, args, generation);
537 }
538 return;
539 }
540 const text = str(command.text);
541 if (id === undefined || command.kind !== 'submit' || text === undefined) {
542 return;
543 }
544 if (submitting) {
545 // `reason: 'busy'`: Para Code waits for the first prompt to be taken before it types this one with keys.
546 await request($, 'ack', { sessionId, id, ok: false, reason: 'busy' });
547 return;
548 }
549 // Taken before any await, so a second prompt of the same batch sees it.
550 submitting = true;
551 const mine = ++submitGeneration;
552 try {
553 // The session moved on (a new session.start, /clear) since this prompt was handed over: it was for the old one.
554 if (generation !== pumpGeneration || !active || await $.session.id() !== sessionId) {
555 await request($, 'ack', { sessionId, id, ok: false, reason: 'stale' });
556 return;
557 }
558 if (await panelOpen($)) {
559 // A screen such as /config holds the keys: the prompt would wait behind it.
560 await request($, 'ack', { sessionId, id, ok: false, reason: 'panel-open' });
561 return;
562 }
563 submitWatch = { id, text };
564 // Tell Para Code first that the prompt is ours now: if a turn started meanwhile, $.prompt.submit
565 // only resolves once that turn ends, and Para Code must not send the same text again with keys.
566 await request($, 'ack', { sessionId, id, received: true });
567 let ok = false;
568 let message: string | undefined;
569 try {
570 await $.prompt.submit({ text, asUser: true });
571 ok = true;
572 } catch (error) {
573 ok = false;
574 message = errorText(error);
575 }
576 const uuid = submitWatch?.id === id ? submitWatch.uuid : undefined;
577 submitWatch = undefined;
578 await request($, 'ack', { sessionId, id, ok, ...(ok ? {} : { reason: 'refused' }), ...(message !== undefined ? { message } : {}), ...(uuid !== undefined ? { uuid } : {}) });
579 } finally {
580 if (submitGeneration === mine) {
581 submitting = false;
582 }
583 }
584}
585
586function schedulePump($: Engine, generation: number, delayMs: number): void {
587 $.clock.after(delayMs, () => {
588 void pumpOnce($, generation);
589 });
590}
591
592/** Holds one request open with Para Code for commands, then asks again. */
593async function pumpOnce($: Engine, generation: number): Promise<void> {
594 if (generation !== pumpGeneration || !active) {
595 return;
596 }
597 let delayMs = 1;
598 try {
599 const sessionId = await $.session.id();
600 const reply = await request($, 'commands', { sessionId, busy: mainBusy, features: MOD_FEATURES });
601 if (reply === undefined || reply.status !== 200) {
602 delayMs = 5_000;
603 } else {
604 const commands = Array.isArray(reply.json?.commands) ? reply.json.commands : [];
605 for (const command of commands.slice(0, 8)) {
606 const record = rec(command);
607 if (record === undefined) {
608 continue;
609 }
610 // Never hold the loop on a command: a prompt's `$.prompt.submit` only resolves once a running turn
611 // ends, and a stop must still reach the task meanwhile. A prompt that comes while another is being
612 // submitted is refused (runCommand), never queued.
613 void runCommand($, sessionId, record, generation).catch(() => undefined);
614 }
615 }
616 } catch {
617 delayMs = 5_000;
618 }
619 if (generation === pumpGeneration && active) {
620 schedulePump($, generation, delayMs);
621 }
622}
623
624export const register: Register = on => {
625 on('session.start', async ($, e, next) => {
626 const started = await next(e);
627 active = false;
628 pumpGeneration++;
629 // A new session (or a reload of this module) starts with nothing being sent and no panel of ours open.
630 submitting = false;
631 submitGeneration++;
632 if (e.isInteractive && await resolveEndpoint($, true) !== undefined) {
633 active = true;
634 mainBusy = false;
635 emit($, { type: 'hello', version: MOD_VERSION });
636 schedulePump($, pumpGeneration, 1);
637 }
638 return started;
639 });
640
641 on('session.end', ($, e, next) => {
642 if (active) {
643 emit($, { type: 'bye', reason: e.reason });
644 }
645 return next(e);
646 });
647
648 on('session.append', ($, e, next) => {
649 if (active) {
650 try {
651 observeRow($, e);
652 } catch {
653 // Observing never stands in the way of the row.
654 }
655 }
656 return next(e);
657 });
658
659 on('turn.start', ($, e, next) => {
660 if (active) {
661 mainBusy = true;
662 emit($, { type: 'turn.start', turnId: e.turnId });
663 }
664 return next(e);
665 });
666
667 on('turn.complete', ($, e, next) => {
668 if (active) {
669 if (e.agentId === undefined) {
670 mainBusy = false;
671 }
672 emit($, { type: 'turn.complete', turnId: e.turnId, reason: e.reason, aborted: e.isAborted, ...(e.agentId !== undefined ? { agentId: e.agentId } : {}) });
673 }
674 return next(e);
675 });
676
677 // The context window as the status line shows it (Para Code Mobile's session ring). Older Claude Code has no
678 // session.measure; registering it must not cost the other hooks.
679 try {
680 on('session.measure', ($, e, next) => {
681 if (active && e.changed.includes('context')) {
682 const { tokens, window, percent } = e.context;
683 emit($, { type: 'measure', context: { window, ...(tokens !== undefined ? { tokens } : {}), ...(percent !== undefined ? { percent } : {}) } });
684 }
685 return next(e);
686 });
687 } catch {
688 // This Claude Code does not measure sessions: Para Code reads the transcript's usage instead.
689 }
690
691 on('turn.step', async function* ($, e, next) {
692 const stream = next(e);
693 if (!active || e.agentId !== undefined) {
694 return yield* stream;
695 }
696 let chunks: { index: number; text: string }[] = [];
697 let sentAt = Date.now();
698 const send = (end: boolean) => {
699 if (chunks.length > 0 || end) {
700 emit($, { type: 'step', turnId: e.turnId, step: e.index, chunks, end });
701 chunks = [];
702 sentAt = Date.now();
703 }
704 };
705 for await (const chunk of stream) {
706 if (chunk.kind === 'text' && chunk.text.length > 0) {
707 const last = chunks[chunks.length - 1];
708 if (last !== undefined && last.index === chunk.index) {
709 last.text += chunk.text;
710 } else {
711 chunks.push({ index: chunk.index, text: chunk.text });
712 }
713 if (Date.now() - sentAt >= STEP_FLUSH_MS) {
714 send(false);
715 }
716 }
717 yield chunk;
718 }
719 send(true);
720 return await stream.result;
721 });
722
723 on('tool.check', async ($, e, next) => {
724 const verdict = await next(e);
725 if (active && verdict.decision === 'ask' && e.tool_use_id !== undefined) {
726 rememberAsk(e.tool, e.input, e.tool_use_id);
727 emit($, { type: 'tool.check', toolUseId: e.tool_use_id, tool: e.tool });
728 }
729 return verdict;
730 });
731
732 on('classic.SubagentStart', ($, e, next) => {
733 if (active) {
734 emit($, { type: 'subagent.resume', agentId: e.agent_id, ...(str(e.agent_type) !== undefined ? { agentType: e.agent_type } : {}) }, e.session_id);
735 }
736 return next(e);
737 });
738
739 on('tool.call', async ($, e, next) => {
740 if (!active) {
741 return next(e);
742 }
743 const toolName = String(e.tool);
744 if (pendingPermissions.size > 0) {
745 settleForCall($, await $.session.id(), e.tool_use_id, toolName);
746 }
747 if (e.tool === 'AskUserQuestion') {
748 const sessionId = await $.session.id();
749 const asked = rec(e.answers);
750 if (asked !== undefined && Object.keys(asked).length > 0) {
751 return next(e);
752 }
753 const registered = await request($, 'question', {
754 sessionId, questions: e.questions,
755 ...(e.tool_use_id !== undefined ? { toolUseId: e.tool_use_id } : {}),
756 ...(e.agentId !== undefined ? { agentId: e.agentId } : {}),
757 });
758 const id = str(registered?.json?.id);
759 if (registered?.status !== 200 || id === undefined || registered.json?.wait !== true) {
760 return next(e);
761 }
762 // Race the terminal's own dialog with Para Code Mobile: the first answer is the answer.
763 let finished = false;
764 const fromTerminal = next(e).then(result => ({ kind: 'terminal' as const, result }));
765 const fromMobile = waitFor($, sessionId, id, () => finished).then(reply => ({ kind: 'mobile' as const, reply }));
766 const first = await Promise.race([fromTerminal, fromMobile]);
767 finished = true;
768 if (first.kind === 'mobile') {
769 const answers = rec(first.reply?.answers);
770 if (first.reply?.state === 'answer' && answers !== undefined) {
771 const annotations = rec(first.reply?.annotations);
772 return { result: { questions: e.questions, answers, ...(annotations !== undefined ? { annotations } : {}) } };
773 }
774 // "Chat about this" from the phone: every question is withdrawn. With a message the model
775 // reads "The user responded: …"; without one it gets the same refusal the terminal's own
776 // "Chat about this" writes (Para Code builds the text, with the partial answers and notes).
777 if (first.reply?.state === 'clarify') {
778 const response = str(first.reply.response);
779 if (response !== undefined && response.trim().length > 0) {
780 return { result: { questions: e.questions, answers: {}, response } };
781 }
782 const deny = str(first.reply.deny);
783 if (deny !== undefined && deny.length > 0) {
784 return { deny };
785 }
786 }
787 // Nothing came from the phone (it went away, or Para Code did): the dialog decides.
788 return (await fromTerminal).result;
789 }
790 settle($, sessionId, [id]);
791 return first.result;
792 }
793 if (toolName === 'Agent' || toolName === 'Task') {
794 const started = await next(e);
795 const result = rec(started.result);
796 const agentId = str(result?.agentId) ?? str(result?.agent_id);
797 if (agentId !== undefined) {
798 const input = e as unknown as Json;
799 emit($, {
800 type: 'subagent.start', agentId,
801 ...(e.tool_use_id !== undefined ? { toolUseId: e.tool_use_id } : {}),
802 ...(str(input.subagent_type) !== undefined ? { subagentType: input.subagent_type } : {}),
803 ...(str(input.description) !== undefined ? { description: input.description } : {}),
804 ...(str(input.name) !== undefined ? { name: input.name } : {}),
805 });
806 }
807 return started;
808 }
809 return next(e);
810 });
811
812 on('classic.PermissionRequest', async ($, e, next) => {
813 // The settings hooks (Para Code's own notification among them) run first, as without this mod.
814 const decided = await next(e);
815 if (!active || decided.decision !== undefined || decided.block !== undefined || e.tool_name === 'AskUserQuestion') {
816 return decided;
817 }
818 const sessionId = e.session_id;
819 const toolUseId = takeAskedCall(e.tool_name, e.tool_input);
820 const suggestions = Array.isArray(e.permission_suggestions) ? e.permission_suggestions : [];
821 // denyMessage: this mod hands the refusal text Para Code sends back to Claude Code (older mods use a fixed one).
822 const registered = await request($, 'permission', {
823 sessionId, toolName: e.tool_name, toolInput: e.tool_input, suggestions, denyMessage: true,
824 ...(toolUseId !== undefined ? { toolUseId } : {}),
825 ...(e.agent_id !== undefined ? { agentId: e.agent_id } : {}),
826 });
827 const id = str(registered?.json?.id);
828 if (registered?.status !== 200 || id === undefined || registered.json?.wait !== true) {
829 return decided;
830 }
831 // The terminal's own permission prompt is up beside this wait. Whichever answers first wins:
832 // an answer in the terminal starts the call (tool.call settles this wait) or writes its
833 // refusal (Para Code settles it from the row).
834 pendingPermissions.set(id, { toolUseId, toolName: e.tool_name });
835 try {
836 const reply = await waitFor($, sessionId, id, () => !pendingPermissions.has(id));
837 if (reply?.state === 'answer' && reply.decision === 'allow') {
838 return {
839 ...decided,
840 decision: { behavior: 'allow', ...(reply.always === true && suggestions.length > 0 ? { updatedPermissions: suggestions } : {}) },
841 };
842 }
843 if (reply?.state === 'answer' && reply.decision === 'deny') {
844 // With an instruction from the phone, Para Code sends the same refusal the terminal's
845 // "No, and tell Claude what to do differently" writes, so the model reads it as the user's.
846 const message = str(reply.message);
847 return { ...decided, decision: { behavior: 'deny', message: message !== undefined && message.length > 0 ? message : 'Denied from Para Code Mobile.' } };
848 }
849 return decided;
850 } finally {
851 pendingPermissions.delete(id);
852 }
853 });
854};
855