@zergai/ztc 0.3.0-beta.1 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/bin/zmux-darwin-arm64 +0 -0
  2. package/bin/ztc-tui-darwin-arm64 +0 -0
  3. package/bin/zterm-darwin-arm64 +0 -0
  4. package/dist/App.js +16 -2
  5. package/dist/agent/agent.d.ts +20 -8
  6. package/dist/agent/agent.js +92 -40
  7. package/dist/agent/backends/gemini.js +1 -1
  8. package/dist/agent/backends/openai_format.js +2 -2
  9. package/dist/agent/backends/openai_responses.js +1 -1
  10. package/dist/agent/backends/types.d.ts +1 -0
  11. package/dist/agent/commands/config.js +15 -1
  12. package/dist/agent/commands/settings.js +23 -0
  13. package/dist/agent/commands/status.js +3 -0
  14. package/dist/agent/commands/types.d.ts +2 -0
  15. package/dist/agent/completion_state.js +2 -2
  16. package/dist/agent/factory.js +7 -1
  17. package/dist/agent/request_encoder.js +3 -0
  18. package/dist/agent/runtime/command_jobs.d.ts +47 -0
  19. package/dist/agent/runtime/command_jobs.js +268 -0
  20. package/dist/agent/runtime/execution_context.d.ts +16 -0
  21. package/dist/agent/runtime/execution_context.js +20 -0
  22. package/dist/agent/runtime/recovery.d.ts +9 -0
  23. package/dist/agent/runtime/recovery.js +71 -0
  24. package/dist/agent/runtime/tool_runtime.js +3 -1
  25. package/dist/agent/runtime/tool_scheduler.d.ts +6 -0
  26. package/dist/agent/runtime/tool_scheduler.js +57 -0
  27. package/dist/agent/subagent.js +30 -12
  28. package/dist/agent/tool_response_store.d.ts +2 -0
  29. package/dist/agent/tool_response_store.js +1 -0
  30. package/dist/agent/tools/deferred.js +2 -0
  31. package/dist/agent/tools/file.js +2 -0
  32. package/dist/agent/tools/pane.js +139 -42
  33. package/dist/agent/tools/recall_tool_response.js +1 -0
  34. package/dist/agent/tools/registry.js +3 -1
  35. package/dist/agent/tools/search.js +1 -0
  36. package/dist/agent/tools/shell.d.ts +2 -0
  37. package/dist/agent/tools/shell.js +57 -2
  38. package/dist/agent/tools/types.d.ts +4 -0
  39. package/dist/app/agent_runner.js +1 -1
  40. package/dist/app/ordered_transcript.d.ts +13 -0
  41. package/dist/app/ordered_transcript.js +55 -0
  42. package/dist/cli_ratatui.d.ts +2 -7
  43. package/dist/cli_ratatui.js +53 -34
  44. package/dist/config/execution.d.ts +12 -0
  45. package/dist/config/execution.js +13 -0
  46. package/dist/config/types.d.ts +1 -0
  47. package/dist/config.d.ts +1 -0
  48. package/dist/config.js +5 -0
  49. package/dist/emulation/tool_adapters.js +5 -2
  50. package/dist/headless.d.ts +1 -18
  51. package/dist/headless.js +15 -0
  52. package/dist/plugin/runtime.d.ts +1 -1
  53. package/dist/types.d.ts +3 -0
  54. package/dist/ui/ratatui/headless_pane_manager.d.ts +2 -19
  55. package/dist/ui/ratatui/headless_pane_manager.js +41 -34
  56. package/dist/ui/ratatui/pane_input.d.ts +4 -0
  57. package/dist/ui/ratatui/pane_input.js +15 -0
  58. package/dist/ui/ratatui/pane_relay.d.ts +3 -0
  59. package/dist/ui/ratatui/pane_relay.js +20 -2
  60. package/dist/ui/ratatui/protocol.d.ts +17 -0
  61. package/dist/ui/ratatui/protocol.js +1 -0
  62. package/dist/utils/shell.d.ts +2 -0
  63. package/dist/utils/shell.js +54 -11
  64. package/dist/web/mirror_stream.d.ts +2 -1
  65. package/dist/web/mirror_stream.js +7 -0
  66. package/dist/web/pane_registry.js +8 -2
  67. package/dist/web/view_plugin.d.ts +1 -0
  68. package/dist/zlm-embed.mjs +384 -118
  69. package/package.json +2 -2
@@ -1,3 +1,5 @@
1
+ import { reconcileOrderedTranscript } from './app/ordered_transcript.js';
2
+ import { shutdownCommandJobs, subscribeCommandJobs } from './agent/runtime/command_jobs.js';
1
3
  /**
2
4
  * RatatuiApp — the interactive TUI application class.
3
5
  *
@@ -1732,6 +1734,7 @@ export class RatatuiApp {
1732
1734
  retry: () => { },
1733
1735
  exit: () => this.exit(),
1734
1736
  getSessionId: () => this.sessionId,
1737
+ getExecutionContext: () => this.agent?.getExecutionContext(),
1735
1738
  getSessionName: () => this.sessionName,
1736
1739
  setSessionName: (name) => {
1737
1740
  this.sessionName = name && name.length > 0 ? name : undefined;
@@ -7255,6 +7258,17 @@ export class RatatuiApp {
7255
7258
  }
7256
7259
  // Snapshot message count before the run so we can roll back on cancel
7257
7260
  const preRunMessageCount = this.controller.getState().messages.length;
7261
+ let historyCheckpoint = [];
7262
+ const transcriptIterations = [];
7263
+ const toolIterations = new Map();
7264
+ const retainCheckpoint = () => {
7265
+ if (!historyCheckpoint.length)
7266
+ return;
7267
+ this.controller.setMessages(reconcileOrderedTranscript({
7268
+ live: this.controller.getState().messages, ordered: historyCheckpoint,
7269
+ iterations: transcriptIterations, toolIterations,
7270
+ }));
7271
+ };
7258
7272
  // Any steering retained across a prior interrupted or widget-ended turn
7259
7273
  // is already present in this snapshot. It must not be injected twice.
7260
7274
  this.pendingSteering = [];
@@ -7287,9 +7301,13 @@ export class RatatuiApp {
7287
7301
  // Liveness clock: any agent event proves the model is still producing.
7288
7302
  this.lastAgentEventAt = Date.now();
7289
7303
  switch (event.type) {
7304
+ case 'history_checkpoint':
7305
+ historyCheckpoint = event.messages;
7306
+ break;
7290
7307
  case 'thinking_start': {
7291
7308
  this.flushLocalStreamDelta();
7292
7309
  this.finishLocalStreamingMessages();
7310
+ transcriptIterations.push({ start: this.controller.getState().messages.length });
7293
7311
  this.controller.setAgentState(applyAgentEventToState(this.controller.getState().agentState, event));
7294
7312
  break;
7295
7313
  }
@@ -7297,6 +7315,11 @@ export class RatatuiApp {
7297
7315
  this.flushLocalStreamDelta();
7298
7316
  this.finishLocalStreamingMessages();
7299
7317
  this.controller.setAgentState(applyAgentEventToState(this.controller.getState().agentState, event));
7318
+ const frameIndex = Math.max(0, transcriptIterations.length - 1);
7319
+ if (!transcriptIterations.length)
7320
+ transcriptIterations.push({ start: preRunMessageCount });
7321
+ transcriptIterations[frameIndex].toolStart ??= this.controller.getState().messages.length;
7322
+ toolIterations.set(event.toolCallId, frameIndex);
7300
7323
  this.turnToolCallCount++;
7301
7324
  const toolCallId = event.toolCallId;
7302
7325
  this.mirror?.publishToolCall(event.tool, event.args || {}, toolCallId);
@@ -7449,6 +7472,7 @@ export class RatatuiApp {
7449
7472
  }
7450
7473
  }
7451
7474
  catch (err) {
7475
+ retainCheckpoint();
7452
7476
  this.flushLocalStreamDelta();
7453
7477
  this.finishLocalStreamingMessages();
7454
7478
  this.finishToolGroups(err.message);
@@ -7496,6 +7520,7 @@ export class RatatuiApp {
7496
7520
  this.finishLocalStreamingMessages();
7497
7521
  this.finishToolGroups(cancelled ? 'Cancelled' : 'No result received');
7498
7522
  if (cancelled) {
7523
+ retainCheckpoint();
7499
7524
  // Clean up partial state from cancelled run
7500
7525
  this.clearLocalStreamDelta();
7501
7526
  // Codex parity: an interrupt stops the run but keeps everything the run
@@ -7534,41 +7559,15 @@ export class RatatuiApp {
7534
7559
  // calls onto the final assistant message, which would replay a steering
7535
7560
  // prompt before the tool result it followed.
7536
7561
  const turnMessages = result.turnMessages ?? [];
7537
- const steeredTurn = turnMessages.some(message => message.metadata?.steering === true);
7538
- if (steeredTurn) {
7539
- const live = this.controller.getState().messages;
7540
- // A steering message the turn actually consumed is replayed from
7541
- // `turnMessages`, so keeping the live copy would duplicate it. One still
7542
- // in `pendingSteering` never met a tool boundary: dropping it here loses
7543
- // the prompt from both the transcript and every request. Keep it, and
7544
- // `movePendingSteeringToEnd` will place it after the answer and take the
7545
- // follow-up turn.
7546
- const unconsumedSteering = new Set(this.pendingSteering.map(message => message.id));
7547
- const retainedMessages = live.slice(preRunMessageCount)
7548
- .filter(message => message.role === 'system' || message.role === 'tool'
7549
- || (message.role === 'user'
7550
- && (message.metadata?.steering !== true || unconsumedSteering.has(message.id))));
7551
- const orderedMessages = turnMessages.map(message => ({
7552
- ...message,
7553
- ...(message.toolCalls ? {
7554
- toolCalls: message.toolCalls.map(toolCall => {
7555
- const { result: _result, error: _error, ...lightCall } = toolCall;
7556
- return lightCall;
7557
- }),
7558
- metadata: { ...message.metadata, toolCallsInTranscript: true },
7559
- } : {}),
7560
- }));
7561
- const finalAssistant = result.content.trim()
7562
- ? {
7563
- id: generateId(), role: 'assistant', content: result.content,
7564
- timestamp: new Date(),
7562
+ if (turnMessages.length > 0) {
7563
+ this.controller.setMessages(reconcileOrderedTranscript({
7564
+ live: this.controller.getState().messages, ordered: turnMessages,
7565
+ iterations: transcriptIterations, toolIterations,
7566
+ finalMessage: result.content.trim() ? {
7567
+ id: generateId(), role: 'assistant', content: result.content, timestamp: new Date(),
7565
7568
  ...(result.reasoning ? { metadata: { reasoningContent: result.reasoning } } : {}),
7566
- }
7567
- : null;
7568
- this.controller.setMessages([
7569
- ...live.slice(0, preRunMessageCount), ...orderedMessages,
7570
- ...(finalAssistant ? [finalAssistant] : []), ...retainedMessages,
7571
- ]);
7569
+ } : undefined,
7570
+ }));
7572
7571
  }
7573
7572
  else {
7574
7573
  // Add final response if not already streamed
@@ -7628,6 +7627,7 @@ export class RatatuiApp {
7628
7627
  .map(tc => ({
7629
7628
  tool_use_id: tc.id,
7630
7629
  content: tc.error ? `Error: ${tc.error}` : (tc.result || ''),
7630
+ is_error: tc.status === 'error',
7631
7631
  }));
7632
7632
  if (toolResultContent.length > 0) {
7633
7633
  this.controller.addMessage({
@@ -8683,7 +8683,23 @@ export class RatatuiApp {
8683
8683
  }
8684
8684
  return paneMode === 'sim-pane' ? 'sim-pane' : controllerMode;
8685
8685
  }
8686
+ commandJobsUnsubscribe;
8687
+ commandJobStates = new Map();
8686
8688
  createAgent() {
8689
+ this.commandJobsUnsubscribe?.();
8690
+ this.commandJobsUnsubscribe = subscribeCommandJobs(this.sessionId, jobs => {
8691
+ const snapshot = { type: 'command_jobs', data: { jobs } };
8692
+ if (this.mirrorServer)
8693
+ this.mirrorServer.publish(snapshot);
8694
+ else
8695
+ this.mirrorClient?.publish(snapshot);
8696
+ for (const job of jobs) {
8697
+ if (this.commandJobStates.get(job.job_id) !== job.status) {
8698
+ this.commandJobStates.set(job.job_id, job.status);
8699
+ this.addSystemMessage(`Command ${job.job_id}: ${job.status}${job.exit_code === undefined ? '' : ` (exit ${job.exit_code})`}. ${job.command.slice(0, 120)}`);
8700
+ }
8701
+ }
8702
+ });
8687
8703
  const currentMode = this.getEffectiveAgentMode();
8688
8704
  if (currentMode === 'plan' && !this.controller.getPlanFile()) {
8689
8705
  throw new PlanFileCreationError('<unset>', new Error('refusing to construct a plan agent without an artifact path'));
@@ -10270,6 +10286,9 @@ export class RatatuiApp {
10270
10286
  await batch.shutdown(5000);
10271
10287
  clearCurrentBatch();
10272
10288
  }
10289
+ await shutdownCommandJobs(this.sessionId);
10290
+ this.commandJobsUnsubscribe?.();
10291
+ this.commandJobsUnsubscribe = undefined;
10273
10292
  // Shut down background agents before clearing tool callbacks
10274
10293
  const bgRegistry = getBackgroundRegistry();
10275
10294
  if (bgRegistry) {
@@ -0,0 +1,12 @@
1
+ /** Execution controls shared by the agent, command jobs, and settings surfaces. */
2
+ export interface ExecutionSettings {
3
+ shellPanePolicy?: 'on_request' | 'auto';
4
+ readConcurrency?: number;
5
+ maxCallsPerResponse?: number;
6
+ recovery?: 'automatic' | 'pause';
7
+ repeatedFailureLimit?: number;
8
+ recoveryAttempts?: number;
9
+ interruptBackgroundJobs?: boolean;
10
+ maxBackgroundJobs?: number;
11
+ }
12
+ export declare function resolveExecutionSettings(settings?: ExecutionSettings): Required<ExecutionSettings>;
@@ -0,0 +1,13 @@
1
+ export function resolveExecutionSettings(settings = {}) {
2
+ const count = (value, fallback, max, min = 1) => Number.isSafeInteger(value) && value >= min && value <= max ? value : fallback;
3
+ return {
4
+ shellPanePolicy: settings.shellPanePolicy === 'auto' ? 'auto' : 'on_request',
5
+ readConcurrency: count(settings.readConcurrency, 4, 32),
6
+ maxCallsPerResponse: count(settings.maxCallsPerResponse, 16, 256),
7
+ recovery: settings.recovery === 'pause' ? 'pause' : 'automatic',
8
+ repeatedFailureLimit: count(settings.repeatedFailureLimit, 3, 20),
9
+ recoveryAttempts: count(settings.recoveryAttempts, 1, 5, 0),
10
+ interruptBackgroundJobs: settings.interruptBackgroundJobs === true,
11
+ maxBackgroundJobs: count(settings.maxBackgroundJobs, 4, 32),
12
+ };
13
+ }
@@ -326,6 +326,7 @@ export interface ZTCConfig {
326
326
  contextProgressBar?: ContextProgressBarSettings;
327
327
  /** Retry and timeout policy for LLM requests. */
328
328
  apiRetry?: ApiRetrySettings;
329
+ execution?: import('./execution.js').ExecutionSettings;
329
330
  /** Terminal transcript density and reasoning visibility. */
330
331
  messageDisplay?: MessageDisplaySettings;
331
332
  /** Context compaction strategy (summarize, or Jev delete-only). */
package/dist/config.d.ts CHANGED
@@ -49,6 +49,7 @@ export declare class ConfigStore {
49
49
  getConfigWithoutEffortOverride(): ZTCConfig;
50
50
  private composeConfig;
51
51
  private cliOverridesWithoutEffort;
52
+ getMaxTokensSource(): string;
52
53
  getConfigScope(): ZTCConfigScope;
53
54
  setConfigScope(scope: ZTCConfigScope): void;
54
55
  clearSessionOverrides(): void;
package/dist/config.js CHANGED
@@ -734,6 +734,11 @@ export class ConfigStore {
734
734
  ...(this.cliOverrides.model !== undefined ? { model: this.cliOverrides.model } : {}),
735
735
  };
736
736
  }
737
+ getMaxTokensSource() {
738
+ if (Object.prototype.hasOwnProperty.call(this.sessionOverrides, 'maxTokens'))
739
+ return 'session override';
740
+ return this.config.maxTokens === undefined ? 'provider default' : `saved profile (${this.getConfigPath()})`;
741
+ }
737
742
  getConfigScope() {
738
743
  return this.configScope;
739
744
  }
@@ -14,7 +14,7 @@ export function buildEmulationCompatibilityTools(emulationId, tools) {
14
14
  return;
15
15
  }
16
16
  existing.add(tool.definition.name);
17
- adapters.push(tool);
17
+ adapters.push({ ...tool, parallelSafe: ['Read', 'Grep', 'LS'].includes(tool.definition.name) });
18
18
  };
19
19
  const factories = [];
20
20
  if (CLAUDE_PROFILE_IDS.has(emulationId)) {
@@ -286,7 +286,10 @@ async function callTool(tools, name, args, context) {
286
286
  if (!tool) {
287
287
  throw new Error(`Compatibility adapter target is unavailable: ${name}`);
288
288
  }
289
- return tool.execute(args, context);
289
+ const output = await tool.execute(args, context);
290
+ if (tool.isErrorResult?.(output))
291
+ throw new Error(output);
292
+ return output;
290
293
  }
291
294
  async function executeCodexShell(tools, args, context) {
292
295
  const command = requiredString(args, ['cmd', 'command']);
@@ -1,21 +1,3 @@
1
- /**
2
- * Headless Mode for ZTC
3
- *
4
- * Enables programmatic control of ZTC via stdin/stdout.
5
- * Useful for:
6
- * - Automated testing of modes (e.g., DCAIG)
7
- * - CI/CD pipelines
8
- * - Agent orchestration (outer agent driving ZTC)
9
- *
10
- * Headless shell mode:
11
- * - `ZTC_HEADLESS_SHELL_MODE=real` allows slash-command shell execution on the host
12
- * - `ZTC_HEADLESS_SHELL_MODE=stub` keeps shell calls inert and returns empty results
13
- * - default: `real`
14
- *
15
- * Usage:
16
- * echo "build a JIRA connector" | ztc --headless --mode ~/dcaig/ztc/modes/dcaig.yaml
17
- * echo '{"prompt": "...", "session_id": "..."}' | ztc --headless --json
18
- */
19
1
  import type { MessageAttachment } from './types.js';
20
2
  import type { CodexReasoningEffort } from './config/types.js';
21
3
  interface HeadlessOptions {
@@ -217,6 +199,7 @@ export declare class HeadlessApp {
217
199
  /**
218
200
  * Create the agent
219
201
  */
202
+ private commandJobsUnsubscribe;
220
203
  private createAgent;
221
204
  private bindAgentStore;
222
205
  private awaitAgentBind;
package/dist/headless.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { shutdownCommandJobs, subscribeCommandJobs } from './agent/runtime/command_jobs.js';
1
2
  /**
2
3
  * Headless Mode for ZTC
3
4
  *
@@ -743,6 +744,10 @@ export class HeadlessApp {
743
744
  // Always show errors
744
745
  console.error(`${ts}[error] ${data.data.message}`);
745
746
  break;
747
+ case 'command_jobs':
748
+ if (!this.options.quiet || this.options.verbose)
749
+ console.log(`${ts}[commands] ${JSON.stringify(data.data.jobs)}`);
750
+ break;
746
751
  case 'status':
747
752
  // In quiet mode, skip status unless verbose
748
753
  if (this.options.quiet && !this.options.verbose)
@@ -1412,7 +1417,13 @@ export class HeadlessApp {
1412
1417
  /**
1413
1418
  * Create the agent
1414
1419
  */
1420
+ commandJobsUnsubscribe;
1415
1421
  createAgent() {
1422
+ this.commandJobsUnsubscribe?.();
1423
+ this.commandJobsUnsubscribe = subscribeCommandJobs(this.sessionId, jobs => {
1424
+ this.output({ type: 'command_jobs', timestamp: new Date().toISOString(), data: { jobs } });
1425
+ this.autoMirror?.publish({ type: 'command_jobs', data: { jobs } });
1426
+ });
1416
1427
  const isEvolveMode = this.currentMode === 'evolved-swarm';
1417
1428
  const hookDispatcher = this.hookLifecycle.refresh();
1418
1429
  const agent = createAgentWithConfig({
@@ -1620,6 +1631,7 @@ export class HeadlessApp {
1620
1631
  },
1621
1632
  setDebug: () => { },
1622
1633
  getSessionId: () => this.sessionId,
1634
+ getExecutionContext: () => this.agent?.getExecutionContext(),
1623
1635
  getSessionName: () => this.sessionName,
1624
1636
  setSessionName: (name) => {
1625
1637
  this.sessionName = name && name.length > 0 ? name : undefined;
@@ -2335,6 +2347,9 @@ export class HeadlessApp {
2335
2347
  * Shut down background agents and batch orchestrator gracefully before exiting.
2336
2348
  */
2337
2349
  async shutdownBackgroundAgents() {
2350
+ await shutdownCommandJobs(this.sessionId);
2351
+ this.commandJobsUnsubscribe?.();
2352
+ this.commandJobsUnsubscribe = undefined;
2338
2353
  if (this.batchEventUnsub) {
2339
2354
  this.batchEventUnsub();
2340
2355
  this.batchEventUnsub = null;
@@ -3,7 +3,7 @@ export type { EmbeddedRuntimeFactory, ZtcPluginContext, ZtcMuxStartContext, ZtcM
3
3
  export type { EmbeddedRuntimeContext, EmbeddedRuntimeSession, } from '../embedded/runtime.js';
4
4
  export type { EmbeddedBackendType, EmbeddedDocPaneDefinition, EmbeddedEmulatorDefinition, EmbeddedFeedPaneDefinition, EmbeddedPaneDefinition, EmbeddedProcessPaneDefinition, EmbeddedRuntimeDefinition, EmbeddedSystemDefinition, EmbeddedSystemSummary, EmbeddedWorkflowDefinition, LoadedEmbeddedSystem, } from '../embedded/types.js';
5
5
  export type { Command, CommandContext, ShellResult } from '../agent/commands/types.js';
6
- export type { MirrorViewState, ViewPlugin, ViewPluginContext } from '../web/view_plugin.js';
6
+ export type { CommandJobView, MirrorViewState, ViewPlugin, ViewPluginContext } from '../web/view_plugin.js';
7
7
  export type { MirrorEvent, MirrorPaneRecord } from '../web/mirror_stream.js';
8
8
  export { COLONY_WORKTREE_MARKER, generateSessionId, getMuxStatePathForCwd, getSessionDirForCwd, getSessionPathForCwd, loadSessionById, saveSession, } from '../session.js';
9
9
  export type { PaneCommandTransport } from '../ui/ratatui/pane_relay.js';
package/dist/types.d.ts CHANGED
@@ -152,6 +152,9 @@ export interface ToolDefinition {
152
152
  overlapsProviderHarness?: boolean;
153
153
  }
154
154
  export type AgentEvent = {
155
+ type: 'history_checkpoint';
156
+ messages: Message[];
157
+ } | {
155
158
  type: 'thinking_start';
156
159
  } | {
157
160
  type: 'tool_start';
@@ -1,16 +1,3 @@
1
- /**
2
- * HeadlessPaneManager — manages child processes for headless mux mode.
3
- *
4
- * Implements PaneCommandTransport so pane tools work identically to zmux mode,
5
- * but spawns/manages child processes directly via Node.js instead of going
6
- * through the zmux Rust binary.
7
- *
8
- * Interactive terminal panes (shell, run-command) get a REAL PTY via node-pty
9
- * when it's installed (prompt/echo/line-editing, vim/less/top, colors). ZTC and
10
- * colony panes use piped child_process stdio (they speak the JSON-line pane
11
- * protocol, not a terminal). If node-pty isn't available (e.g. a CI box with no
12
- * native build), terminal panes fall back to the old piped-stdio behavior.
13
- */
14
1
  import type { RustCommand } from './protocol.js';
15
2
  import type { PaneCommandTransport, PaneEventHandler } from './pane_relay.js';
16
3
  export declare function isZtcPaneType(paneType: string): boolean;
@@ -59,12 +46,8 @@ export declare class HeadlessPaneManager implements PaneCommandTransport {
59
46
  on(handler: PaneEventHandler): () => void;
60
47
  private emit;
61
48
  send(cmd: RustCommand): boolean;
62
- /**
63
- * Capability shim. Headless mode never spawns a real binary, so it
64
- * advertises no capabilities. Callers gating on `hasCapability(...)`
65
- * correctly fall back to the legacy path.
66
- */
67
- hasCapability(_name: string): boolean;
49
+ /** Headless implements correlated pane reads and delivery receipts itself. */
50
+ hasCapability(name: string): boolean;
68
51
  /**
69
52
  * Headless mode has no native renderer to read the clipboard, so image
70
53
  * pastes are unavailable (mirror clients attach images over HTTP instead).
@@ -1,3 +1,5 @@
1
+ import { stripPasteMarkers } from './pane_input.js';
2
+ import { CAPABILITY_PANE_DELIVERY_ACK } from './protocol.js';
1
3
  /**
2
4
  * HeadlessPaneManager — manages child processes for headless mux mode.
3
5
  *
@@ -245,13 +247,9 @@ export class HeadlessPaneManager {
245
247
  return true;
246
248
  }
247
249
  }
248
- /**
249
- * Capability shim. Headless mode never spawns a real binary, so it
250
- * advertises no capabilities. Callers gating on `hasCapability(...)`
251
- * correctly fall back to the legacy path.
252
- */
253
- hasCapability(_name) {
254
- return false;
250
+ /** Headless implements correlated pane reads and delivery receipts itself. */
251
+ hasCapability(name) {
252
+ return name === CAPABILITY_PANE_DELIVERY_ACK;
255
253
  }
256
254
  /**
257
255
  * Headless mode has no native renderer to read the clipboard, so image
@@ -355,6 +353,16 @@ export class HeadlessPaneManager {
355
353
  launch: { command: cmd.command, args, cwd, requestId: cmd.request_id },
356
354
  };
357
355
  const appendOutput = (text) => {
356
+ const modeText = (pane.terminalModeTail ?? '') + text;
357
+ for (const match of modeText.matchAll(/\x1b(?:\[\?([0-9;]+)([hl])|\[!p|c)/g)) {
358
+ if (!match[1])
359
+ pane.bracketedPaste = false;
360
+ else if (match[1].split(';').includes('2004'))
361
+ pane.bracketedPaste = match[2] === 'h';
362
+ }
363
+ // Retain only an unfinished escape sequence, never replay a completed mode.
364
+ const tail = modeText.slice(modeText.lastIndexOf('\x1b'));
365
+ pane.terminalModeTail = tail.length <= 256 && /^\x1b(?:\[(?:\?[0-9;]*|!)?)?$/.test(tail) ? tail : '';
358
366
  const lines = text.split('\n');
359
367
  for (const line of lines) {
360
368
  pane.outputBuffer.push(line);
@@ -598,13 +606,14 @@ export class HeadlessPaneManager {
598
606
  const pane = this.panes.get(cmd.pane_id) ?? this.closedPanes.get(cmd.pane_id);
599
607
  if (!pane) {
600
608
  // Return a deterministic read event so callers don't hang on timeout.
601
- this.emit({ type: 'pane_read', pane_id: cmd.pane_id, content: '', viewport: cmd.viewport ? true : undefined });
609
+ this.emit({ type: 'pane_read', request_id: cmd.request_id, error: `Pane ${cmd.pane_id} not found`, pane_id: cmd.pane_id, content: '', viewport: cmd.viewport ? true : undefined });
602
610
  return true;
603
611
  }
604
- const lines = cmd.lines ?? MAX_BUFFER_LINES;
612
+ const lines = Math.min(cmd.lines ?? 200, 2000);
605
613
  const content = pane.outputBuffer.slice(-lines).join('\n');
606
614
  this.emit({
607
615
  type: 'pane_read',
616
+ request_id: cmd.request_id,
608
617
  pane_id: cmd.pane_id,
609
618
  content,
610
619
  viewport: cmd.viewport ? true : undefined,
@@ -628,37 +637,34 @@ export class HeadlessPaneManager {
628
637
  }
629
638
  handleWritePane(cmd) {
630
639
  const pane = this.panes.get(cmd.pane_id);
631
- if (!pane || !pane.isAlive || (!pane.process && !pane.pty)) {
632
- this.emit({
633
- type: 'error',
634
- message: `Pane ${cmd.pane_id} not found or not alive`,
635
- });
640
+ const acknowledge = (error, queued = false, bytes) => this.emit({ type: 'pane_write_result',
641
+ request_id: cmd.request_id, pane_id: cmd.pane_id, delivered: !error && !queued, queued,
642
+ ...(bytes === undefined ? {} : { total_bytes: bytes, ...(queued ? {} : { delivered_bytes: error ? 0 : bytes, partial: false }) }),
643
+ ...(error ? { error } : {}) });
644
+ if (!pane || !pane.isAlive || pane.closing || (!pane.process && !pane.pty)) {
645
+ acknowledge(`Pane ${cmd.pane_id} not found or not alive`);
636
646
  return true;
637
647
  }
638
648
  try {
639
- // PTY panes: write raw keystrokes (xterm sends \r and control codes itself);
640
- // the PTY handles echo/line-discipline. Do NOT append \n.
641
649
  if (pane.pty) {
642
- pane.pty.write(cmd.input);
650
+ const text = cmd.input.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+$/, '');
651
+ const payload = cmd.control ? cmd.input.replace(/[\r\n]+$/, '') + '\r'
652
+ : cmd.submit ? (pane.bracketedPaste ? `\x1b[200~${stripPasteMarkers(text)}\x1b[201~\r` : text.replace(/\n/g, '\r') + '\r') : cmd.input;
653
+ pane.pty.write(payload);
654
+ acknowledge(undefined, payload.length > 0, Buffer.byteLength(payload));
643
655
  return true;
644
656
  }
645
- // Control commands ({"__ztc_cmd":...}) are written BARE (one \n-terminated
646
- // line, no prompt envelope) so the child's classifier sees them on the
647
- // control channel. User input to a ZTC pane is wrapped via encodeZtcPaneInput
648
- // as {"type":"prompt","text":...} — that envelope means "verbatim user
649
- // prompt", so its text is never re-interpreted as a control command (a user
650
- // whose message IS a __ztc_cmd JSON string still reaches the LLM). Other pane
651
- // types receive the text as-is. Either way the trailing \n is the readline
652
- // line delimiter on the child (ZTC-18).
653
- const payload = cmd.control
654
- ? (cmd.input.endsWith('\n') ? cmd.input : cmd.input + '\n')
655
- : isZtcPaneType(pane.paneType)
656
- ? encodeZtcPaneInput(cmd.input)
657
- : (cmd.input.endsWith('\n') ? cmd.input : cmd.input + '\n');
658
- pane.process?.stdin?.write(payload);
659
- }
660
- catch {
661
- // Process stdin may be closed
657
+ const stdin = pane.process?.stdin;
658
+ if (!stdin || stdin.destroyed || !stdin.writable)
659
+ throw new Error('Pane input stream is closed');
660
+ // ZTC child prompts and control lines retain their separate JSON protocol.
661
+ const payload = cmd.control ? cmd.input.replace(/\n+$/, '') + '\n'
662
+ : isZtcPaneType(pane.paneType) ? encodeZtcPaneInput(cmd.input)
663
+ : cmd.submit ? cmd.input.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+$/, '') + '\n' : cmd.input;
664
+ stdin.write(payload, error => acknowledge(error?.message, false, Buffer.byteLength(payload)));
665
+ }
666
+ catch (error) {
667
+ acknowledge(error.message);
662
668
  }
663
669
  return true;
664
670
  }
@@ -737,6 +743,7 @@ export class HeadlessPaneManager {
737
743
  }
738
744
  if (pane.isAlive && (pane.process || pane.pty)) {
739
745
  try {
746
+ pane.closing = true;
740
747
  this.killPaneProc(pane, 'SIGTERM');
741
748
  }
742
749
  catch {
@@ -0,0 +1,4 @@
1
+ /** Remove paste delimiters, including ones revealed by removing an inner marker.
2
+ * A stack bounds work to O(n) even for deeply nested input.
3
+ */
4
+ export declare function stripPasteMarkers(text: string): string;
@@ -0,0 +1,15 @@
1
+ /** Remove paste delimiters, including ones revealed by removing an inner marker.
2
+ * A stack bounds work to O(n) even for deeply nested input.
3
+ */
4
+ export function stripPasteMarkers(text) {
5
+ const body = [];
6
+ for (const character of text) {
7
+ body.push(character);
8
+ if (character === '~' && body.length >= 6) {
9
+ const suffix = body.slice(-6).join('');
10
+ if (suffix === '\x1b[200~' || suffix === '\x1b[201~')
11
+ body.length -= 6;
12
+ }
13
+ }
14
+ return body.join('');
15
+ }
@@ -15,6 +15,7 @@ export interface PaneCommandTransport {
15
15
  send(cmd: RustCommand): boolean;
16
16
  on(handler: PaneEventHandler): () => void;
17
17
  readonly isMuxMode: boolean;
18
+ hasCapability?(name: string): boolean;
18
19
  }
19
20
  export declare class PaneRelayServer {
20
21
  private transport;
@@ -35,6 +36,7 @@ export declare class PaneRelayClient implements PaneCommandTransport {
35
36
  private readline;
36
37
  private handlers;
37
38
  private ready;
39
+ private capabilities;
38
40
  private socketPath;
39
41
  constructor(socketPath: string);
40
42
  /** Connect to the parent's relay socket */
@@ -44,6 +46,7 @@ export declare class PaneRelayClient implements PaneCommandTransport {
44
46
  send(cmd: RustCommand): boolean;
45
47
  /** Subscribe to pane events from the parent */
46
48
  on(handler: PaneEventHandler): () => void;
49
+ hasCapability(name: string): boolean;
47
50
  /** Child has pane capabilities via relay */
48
51
  get isMuxMode(): boolean;
49
52
  /** Disconnect from the relay */
@@ -12,7 +12,7 @@
12
12
  import { createServer, createConnection } from 'net';
13
13
  import { createInterface } from 'readline';
14
14
  import { unlinkSync } from 'fs';
15
- import { serializeCommand, parseEvent } from './protocol.js';
15
+ import { serializeCommand, parseEvent, CAPABILITY_PANE_DELIVERY_ACK } from './protocol.js';
16
16
  import { generateIpcPath, isNamedPipePath } from './ipc_path.js';
17
17
  // ============================================================================
18
18
  // PaneRelayServer — runs in parent ZTC (--mux mode)
@@ -22,6 +22,7 @@ const PANE_EVENT_TYPES = new Set([
22
22
  'pane_created',
23
23
  'pane_closed',
24
24
  'pane_read',
25
+ 'pane_write_result',
25
26
  'pane_list',
26
27
  'pane_state',
27
28
  'pane_output',
@@ -47,6 +48,9 @@ export class PaneRelayServer {
47
48
  });
48
49
  this.server = createServer((client) => {
49
50
  this.clients.add(client);
51
+ client.write(JSON.stringify({ type: 'ready', version: 'pane-relay', cols: 0, rows: 0,
52
+ capabilities: this.transport.hasCapability?.(CAPABILITY_PANE_DELIVERY_ACK) ? [CAPABILITY_PANE_DELIVERY_ACK] : [],
53
+ }) + '\n');
50
54
  const rl = createInterface({ input: client, crlfDelay: Infinity });
51
55
  rl.on('line', (line) => {
52
56
  const parsed = parseEvent(line);
@@ -125,6 +129,7 @@ export class PaneRelayClient {
125
129
  readline = null;
126
130
  handlers = new Set();
127
131
  ready = false;
132
+ capabilities = new Set();
128
133
  socketPath;
129
134
  constructor(socketPath) {
130
135
  this.socketPath = socketPath;
@@ -140,7 +145,16 @@ export class PaneRelayClient {
140
145
  this.socket = createConnection(this.socketPath, () => {
141
146
  this.ready = true;
142
147
  this.setupHandlers();
143
- resolve();
148
+ // New parents advertise transport capabilities. Old parents have no
149
+ // handshake, so retain their unconfirmed-delivery path after a short grace.
150
+ const timer = setTimeout(() => { unsubscribe(); resolve(); }, 100);
151
+ const unsubscribe = this.on(event => {
152
+ if (event.type === 'ready') {
153
+ clearTimeout(timer);
154
+ unsubscribe();
155
+ resolve();
156
+ }
157
+ });
144
158
  });
145
159
  this.socket.on('error', (err) => {
146
160
  if (attempts < maxAttempts && err.code === 'ENOENT') {
@@ -167,6 +181,8 @@ export class PaneRelayClient {
167
181
  const event = parseEvent(line);
168
182
  if (!event)
169
183
  return;
184
+ if (event.type === 'ready')
185
+ this.capabilities = new Set(event.capabilities ?? []);
170
186
  for (const handler of this.handlers) {
171
187
  try {
172
188
  handler(event);
@@ -201,6 +217,7 @@ export class PaneRelayClient {
201
217
  this.handlers.add(handler);
202
218
  return () => { this.handlers.delete(handler); };
203
219
  }
220
+ hasCapability(name) { return this.capabilities.has(name); }
204
221
  /** Child has pane capabilities via relay */
205
222
  get isMuxMode() {
206
223
  return this.ready;
@@ -211,6 +228,7 @@ export class PaneRelayClient {
211
228
  this.cleanup();
212
229
  }
213
230
  cleanup() {
231
+ this.capabilities.clear();
214
232
  if (this.readline) {
215
233
  this.readline.close();
216
234
  this.readline = null;