SLOPSHOPPER

para-code

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

newguardprocessnetworktimer
A shopper browsing a rack in a slop shop
README

Para Code

License Issues

Para Code は、AI コーディングエージェント(Claude Code・Codex など)と一緒に開発することを前提に作られたエディタです。Visual Studio Code を fork し、複数リポジトリの並行作業・常駐ターミナル・エージェントセッション管理・内蔵ブラウザ連携・SSH リモート開発・モバイルからの遠隔操作といった機能を継ぎ足してあります。

Para Code とは

このリポジトリは microsoft/vscode を fork した独自エディタです。エディタ本体の土台は本家 VS Code のままで、今後も定期的に upstream の新しいリリースを取り込み続けます。そのうえに、複数のエージェントを並行して走らせながら開発する働き方に合わせた機能を追加しています。

<img alt="Para Code" src=".github/readme/screenshot.png">

主な機能

スペース(ワークスペース)管理

  • 複数のリポジトリ・ワークツリーを常駐させたまま、Extension Host を再起動せずに瞬時に切り替え
  • リモートエクスプローラーの「Para ホスト」ビューで、手元と SSH 先を同じツリーに並べて操作
  • SSH 先とのファイル送受信・ドラッグ&ドロップ・複数ファイルのアップロードにも対応

ターミナル

  • 縦横自由な田の字型(2D グリッド)分割
  • 「常駐ターミナル」でウィンドウや Para Code 自体を閉じても実行中のプロセスが動き続け、次回起動時にそのまま繋ぎ直せる(macOS / Linux)
  • Superset 相当のターミナル履歴サジェストなど、日々のシェル操作を補う拡張

AI エージェント連携

  • Claude Code・Codex など複数のエージェントセッションを一覧・絞り込み・履歴検索
  • 通知(音声報告・サウンド・おやすみモード)とユーザー辞書
  • 使用量ダッシュボード(コスト/トークン切り替え)や RTK のトークン節約ダッシュボード
  • 実行環境(手元/特定の SSH 接続先など)に応じて出し分けられるコマンドプリセット

内蔵ブラウザ

  • CDP で繋いだブラウザタブとエージェントセッションを紐づけ、エージェントが見ている画面を可視化・共有
  • 複数ペインの一覧化・絞り込み・ズーム操作

ファイルビューア / Git

  • Markdown・HTML・PDF・Excel・Word の独自プレビュー(相対パスの画像・実行中の読み込みにも対応)
  • Excel・Word の変更点を左右に並べて見られる差分ビュー
  • ソース管理に分岐元ブランチからの差分を独立して表示する「ブランチの変更点」ビュー、GitHub Issue 連携

モバイル(Para Code Mobile)

  • iPhone / iPad から、PC 上で動いているエージェントセッションを遠隔で確認・操作

各バージョンで実際に何が変わったかは、アプリ内の歯車メニュー →「更新履歴」から確認できます。

開発に参加する

Para Code は本家 VS Code のソースをベースにしているため、ビルド・デバッグの基本的な流れは How to Contribute や Coding Guidelines がそのまま参考になります。そのうえで、この fork 特有の実装ルール(新機能の置き場所・既存ファイルへのパッチ方針・upstream 取り込み手順など)は CLAUDE.md と NOTES.md にまとめてあるので、変更を加える前に一読してください。

フィードバック

本家 VS Code との関係

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 用の開発コンテナ設定が含まれています。

  • Dev Containers を使う場合は、Docker と VS Code(または Para Code)をインストールしたうえで Dev Containers: Clone Repository in Container Volume... コマンドを実行してください。
  • Codespaces を使う場合は、GitHub Codespaces 拡張機能をインストールし、Codespaces: Create New Codespace コマンドを実行してください。

フルビルドには最低でも 4 コア・6 GB の RAM(推奨 8 GB) が必要です。詳細は development container README を参照してください。

行動規範

このプロジェクトに参加するすべての人は、互いに敬意を持って接してください。攻撃的な言動やハラスメントは認められません。問題があれば Issue で報告してください。

ライセンス

Copyright (c) 2015 - present Microsoft Corporation. Para Code による追加・変更部分も含め、MIT ライセンスの下で公開されています。

Source 1 files
hooks/register.ts 855 lines
1/*---------------------------------------------------------------------------------------------
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