zcode-acp-server 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,31 +1,37 @@
1
1
  /**
2
- * Remote access configuration from environment variables.
2
+ * Remote access configuration: user config file first, env fallback.
3
3
  *
4
- * Remote access is opt-in via ZCODE_ACP_REMOTE=1 and REQUIRES a token — the
5
- * endpoint is expected to sit behind a public tunnel (Cloudflare Tunnel, frp),
6
- * so "loopback-only" is never a safe assumption here. A missing token disables
7
- * the feature with a warning instead of failing the bridge: the stdio link to
8
- * the editor must keep working no matter what.
4
+ * Remote access is opt-in and REQUIRES a token — the endpoint is expected to
5
+ * sit behind a public tunnel (Cloudflare Tunnel, frp), so "loopback-only" is
6
+ * never a safe assumption here. A missing token disables the feature with a
7
+ * warning instead of failing the bridge: the stdio link to the editor must
8
+ * keep working no matter what.
9
9
  *
10
- * Variables:
11
- * ZCODE_ACP_REMOTE=1 enable the remote endpoint (gate)
12
- * ZCODE_ACP_REMOTE_TOKEN=<s> auth token (mandatory when enabled)
13
- * ZCODE_ACP_HUB_PORT=8377 hub's fixed port (the one a tunnel maps)
14
- * ZCODE_ACP_HUB_HOST=127.0.0.1 hub bind address (e.g. 0.0.0.0 for a
15
- * containerized tunnel agent)
16
- * ZCODE_ACP_REMOTE_PORT=8378 bridge endpoint start port (auto-increment
17
- * when taken; loopback only)
18
- * ZCODE_ACP_REMOTE_ORIGIN=serve registration origin override (ADR-0016: a
19
- * REPL incubated by the hub in a visible
20
- * terminal registers as "serve" so the
21
- * per-workspace dedupe treats it as THE
22
- * project's remote bridge)
23
- * ZCODE_ACP_REMOTE_PIN_CWD=1 pin every session root to the process cwd
24
- * (bridge-wide serveMode; ADR-0016: keeps
25
- * ADR-0014's whitelist semantics for a
26
- * hub-incubated REPL, whose bridge would
27
- * otherwise trust a remote client's cwd)
10
+ * Sources, in priority order (per field):
11
+ * 1. ~/.config/zcode-acp/config.json `remote` section (XDG_CONFIG_HOME
12
+ * aware) — the authoritative, launch-context-independent source. The
13
+ * hub daemon is idle-exited and re-spawned by arbitrary bridges, so its
14
+ * birth env rotates; user preference must not.
15
+ * 2. Environment variables (unchanged semantics — setups without a file
16
+ * keep working, and env fills any field the file leaves unset):
17
+ * ZCODE_ACP_REMOTE=1 enable the remote endpoint (gate)
18
+ * ZCODE_ACP_REMOTE_TOKEN=<s> auth token (mandatory when enabled)
19
+ * ZCODE_ACP_HUB_PORT=8377 hub's fixed port (the one a tunnel maps)
20
+ * ZCODE_ACP_HUB_HOST=127.0.0.1 hub bind address (e.g. 0.0.0.0 for a
21
+ * containerized tunnel agent)
22
+ * ZCODE_ACP_REMOTE_PORT=8378 bridge endpoint start port (auto-increment
23
+ * when taken; loopback only)
24
+ * ZCODE_ACP_HUB_TERMINAL=0 disable the visible-terminal incubation
25
+ * ZCODE_ACP_HUB_TERMINAL_APP=<name> terminal app for the TUI window
26
+ * ZCODE_ACP_HUB_TERMINAL_COMMAND=<sh> shell command template ({script})
27
+ * 3. Built-in defaults.
28
+ *
29
+ * Process-role plumbing stays env-only by design (never file-configurable):
30
+ * ZCODE_ACP_REMOTE_ORIGIN=serve registration origin override (ADR-0016)
31
+ * ZCODE_ACP_REMOTE_PIN_CWD=1 pin session roots to the process cwd
32
+ * ZCODE_ACP_RESUME_SESSION=<id> per-request boot-resume target (ADR-0017)
28
33
  */
34
+ import { loadUserConfig } from "../config/user-config.js";
29
35
  import { warn } from "../utils.js";
30
36
  export const DEFAULT_HUB_PORT = 8377;
31
37
  export const DEFAULT_BRIDGE_PORT = 8378;
@@ -40,40 +46,68 @@ function parsePort(raw, fallback, envName) {
40
46
  }
41
47
  return port;
42
48
  }
49
+ /** Shared file>env merge for token/ports/host (already validated by the loader). */
50
+ function mergeCommon(env) {
51
+ const file = loadUserConfig(env).remote ?? {};
52
+ return {
53
+ token: file.token ?? (env.ZCODE_ACP_REMOTE_TOKEN ?? "").trim(),
54
+ hubPort: file.hubPort ?? parsePort(env.ZCODE_ACP_HUB_PORT, DEFAULT_HUB_PORT, "ZCODE_ACP_HUB_PORT"),
55
+ hubHost: file.hubHost ?? ((env.ZCODE_ACP_HUB_HOST ?? "").trim() || DEFAULT_HUB_HOST),
56
+ bridgePort: file.bridgePort ??
57
+ parsePort(env.ZCODE_ACP_REMOTE_PORT, DEFAULT_BRIDGE_PORT, "ZCODE_ACP_REMOTE_PORT"),
58
+ };
59
+ }
43
60
  /** Parse remote config; null = disabled (or misconfigured → warned). */
44
61
  export function parseRemoteConfig(env = process.env) {
45
- const gate = (env.ZCODE_ACP_REMOTE ?? "").trim().toLowerCase();
46
- if (!["1", "true", "yes", "on"].includes(gate))
62
+ const file = loadUserConfig(env).remote ?? {};
63
+ // Gate: the file is authoritative when it says anything; env decides only
64
+ // when the file is silent (an explicit `enabled: false` wins over env =1).
65
+ const enabled = file.enabled !== undefined
66
+ ? file.enabled
67
+ : ["1", "true", "yes", "on"].includes((env.ZCODE_ACP_REMOTE ?? "").trim().toLowerCase());
68
+ if (!enabled)
47
69
  return null;
48
- const token = (env.ZCODE_ACP_REMOTE_TOKEN ?? "").trim();
70
+ const { token, hubPort, hubHost, bridgePort } = mergeCommon(env);
49
71
  if (!token) {
50
- warn("remote: ZCODE_ACP_REMOTE is enabled but ZCODE_ACP_REMOTE_TOKEN is missing — " +
72
+ warn("remote: enabled but no token (config file or ZCODE_ACP_REMOTE_TOKEN) — " +
51
73
  "remote access disabled (stdio unaffected)");
52
74
  return null;
53
75
  }
54
76
  return {
55
77
  token,
56
- hubPort: parsePort(env.ZCODE_ACP_HUB_PORT, DEFAULT_HUB_PORT, "ZCODE_ACP_HUB_PORT"),
57
- hubHost: (env.ZCODE_ACP_HUB_HOST ?? "").trim() || DEFAULT_HUB_HOST,
58
- bridgePort: parsePort(env.ZCODE_ACP_REMOTE_PORT, DEFAULT_BRIDGE_PORT, "ZCODE_ACP_REMOTE_PORT"),
78
+ hubPort,
79
+ hubHost,
80
+ bridgePort,
59
81
  origin: (env.ZCODE_ACP_REMOTE_ORIGIN ?? "").trim() === "serve" ? "serve" : "editor",
60
82
  pinCwd: (env.ZCODE_ACP_REMOTE_PIN_CWD ?? "").trim() === "1",
61
83
  };
62
84
  }
63
85
  /** Parse hub-side config for the standalone hub entry (`zcode-acp hub`). */
64
86
  export function parseHubConfig(env = process.env) {
65
- const token = (env.ZCODE_ACP_REMOTE_TOKEN ?? "").trim();
87
+ const { token, hubPort, hubHost, bridgePort } = mergeCommon(env);
66
88
  if (!token) {
67
- warn("hub: ZCODE_ACP_REMOTE_TOKEN is required — refusing to start without auth");
89
+ warn("hub: no token (config file or ZCODE_ACP_REMOTE_TOKEN) — refusing to start without auth");
68
90
  return null;
69
91
  }
70
92
  return {
71
93
  token,
72
- hubPort: parsePort(env.ZCODE_ACP_HUB_PORT, DEFAULT_HUB_PORT, "ZCODE_ACP_HUB_PORT"),
73
- hubHost: (env.ZCODE_ACP_HUB_HOST ?? "").trim() || DEFAULT_HUB_HOST,
74
- bridgePort: parsePort(env.ZCODE_ACP_REMOTE_PORT, DEFAULT_BRIDGE_PORT, "ZCODE_ACP_REMOTE_PORT"),
94
+ hubPort,
95
+ hubHost,
96
+ bridgePort,
75
97
  origin: "editor",
76
98
  pinCwd: false,
77
99
  };
78
100
  }
101
+ /**
102
+ * Terminal incubation preferences for the hub (ADR-0016), merged file > env.
103
+ * Read LIVE by the hub at every incubation so editing the file takes effect
104
+ * without a hub restart — the hub outlives the shells that configured it.
105
+ */
106
+ export function remoteTerminalPrefs(env = process.env) {
107
+ const file = loadUserConfig(env).remote?.terminal ?? {};
108
+ const app = file.app ?? ((env.ZCODE_ACP_HUB_TERMINAL_APP ?? "").trim() || undefined);
109
+ const command = file.command ?? ((env.ZCODE_ACP_HUB_TERMINAL_COMMAND ?? "").trim() || undefined);
110
+ const enabled = file.enabled ?? !["0", "false", "off"].includes((env.ZCODE_ACP_HUB_TERMINAL ?? "").trim().toLowerCase());
111
+ return { enabled, ...(app ? { app } : {}), ...(command ? { command } : {}) };
112
+ }
79
113
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/remote/config.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/remote/config.ts"],"names":[],"mappings":"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"}
@@ -23,6 +23,7 @@
23
23
  * next bridge re-spawns it on demand.
24
24
  */
25
25
  import { type ChildProcess } from "node:child_process";
26
+ import type { TerminalPrefs } from "../config/user-config.js";
26
27
  export interface HubOptions {
27
28
  port: number;
28
29
  host: string;
@@ -93,17 +94,27 @@ export type TerminalLaunch =
93
94
  kind: "openAppArgs";
94
95
  app: string;
95
96
  args: string[];
97
+ }
98
+ /** Warp: refuses `.command` files and its CLI is agent-only, but its URI
99
+ * scheme EXECUTES a script handed to action/new_tab's path param
100
+ * (app/src/uri/mod.rs → open_file; verified on 0.2026.09.02): the hub opens
101
+ * `<scheme>://action/new_tab?path=<script>` and Warp runs it as a new tab
102
+ * in its default mode. Preview uses the warppreview:// scheme. */
103
+ | {
104
+ kind: "warpUri";
105
+ app: string;
106
+ scheme: string;
96
107
  };
97
108
  /**
98
- * Pick how to open the TUI script. The hub is a background daemon — its env
99
- * carries no terminal and macOS has no default-terminal setting — so nothing
100
- * is detected. Priority: the explicit command template (the universal escape
101
- * hatch) → a built-in launcher by app name (aliases are case- and
102
- * `.app`-suffix-insensitive) → plain Terminal.app. Warp gets a special warning
103
- * because it accepts the open but cannot run the script; any other unmatched
109
+ * Pick how to open the TUI script. Preferences arrive pre-merged from
110
+ * `remoteTerminalPrefs` (config file first, env fallback — see config.ts);
111
+ * only the app-name normalization lives here. Priority: the explicit command
112
+ * template (the universal escape hatch) → a built-in launcher by app name
113
+ * (aliases are case- and `.app`-suffix-insensitive) → plain Terminal.app
114
+ * (macOS has no default-terminal setting to detect). Any other unmatched
104
115
  * name passes through to `open -a` unchanged.
105
116
  */
106
- export declare function resolveTerminalLaunch(env: NodeJS.ProcessEnv): {
117
+ export declare function resolveTerminalLaunch(env: NodeJS.ProcessEnv, prefs?: TerminalPrefs): {
107
118
  launch: TerminalLaunch;
108
119
  warning?: string;
109
120
  };
@@ -118,6 +129,14 @@ export declare function resolveTerminalLaunch(env: NodeJS.ProcessEnv): {
118
129
  export declare function terminalTuiScript(cwd: string, cliJs: string, env: NodeJS.ProcessEnv): string;
119
130
  /** Reset the quota cache (test helper). */
120
131
  export declare function resetQuotaCacheForTest(): void;
132
+ /**
133
+ * Tab-title sanitizer: the value is printf'd into the terminal as an OSC
134
+ * payload, so control characters (an ESC inside a model-generated summary
135
+ * would inject terminal sequences) become spaces, whitespace collapses, and
136
+ * the length caps at what a tab can usefully show. Empty/non-string →
137
+ * undefined (the caller falls back to the project name).
138
+ */
139
+ export declare function sanitizeTabTitle(v: unknown): string | undefined;
121
140
  /**
122
141
  * Start the hub. Resolves once listening; rejects on bind failure (including
123
142
  * EADDRINUSE when another hub already owns the port).
@@ -1 +1 @@
1
- {"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AA2B9D,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;QACvB,sFAAsF;QACtF,IAAI,EAAE,KAAK,GAAG,OAAO,CAAC;KACvB,KAAK,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAwID,sEAAsE;AACtE,MAAM,MAAM,cAAc;AACxB;uEACuE;AACrE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AACpC,6EAA6E;GAC3E;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AAClC;kEACkE;GAChE;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AA0BzD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG;IAC7D,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAkBA;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,CAY5F;AAgJD,2CAA2C;AAC3C,wBAAgB,sBAAsB,IAAI,IAAI,CAG7C;AA4JD;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,UAAU,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,GAAG,OAAO,CAAC,SAAS,CAAC,CAw0B9F"}
1
+ {"version":3,"file":"hub-server.d.ts","sourceRoot":"","sources":["../../src/remote/hub-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAS,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAwB9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAM9D,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;QACvB,sFAAsF;QACtF,IAAI,EAAE,KAAK,GAAG,OAAO,CAAC;KACvB,KAAK,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAsID,sEAAsE;AACtE,MAAM,MAAM,cAAc;AACxB;uEACuE;AACrE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AACpC,6EAA6E;GAC3E;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AAClC;kEACkE;GAChE;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE;AACtD;;;;kEAIkE;GAChE;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AA2BrD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,MAAM,CAAC,UAAU,EACtB,KAAK,GAAE,aAAwC,GAC9C;IACD,MAAM,EAAE,cAAc,CAAC;IACvB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAUA;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,CAqB5F;AA6JD,2CAA2C;AAC3C,wBAAgB,sBAAsB,IAAI,IAAI,CAG7C;AAmCD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAQ/D;AAyID;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,UAAU,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;CAAE,GAAG,OAAO,CAAC,SAAS,CAAC,CA22B9F"}
@@ -35,7 +35,9 @@ import { tmpdir } from "node:os";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { WebSocket, WebSocketServer } from "ws";
37
37
  import { AGENT_INFO, compareVersions, log, warn } from "../utils.js";
38
+ import { remoteTerminalPrefs } from "./config.js";
38
39
  import { accountUsageStats } from "../handlers/account.js";
40
+ import { BOOT_RESUME_TRIGGER } from "../handlers/session.js";
39
41
  import { listKnownWorkspaces } from "../tasks-index.js";
40
42
  const HEARTBEAT_TIMEOUT_MS = 30_000;
41
43
  const IDLE_EXIT_MS = 10 * 60_000;
@@ -128,8 +130,6 @@ function parseOrigin(raw) {
128
130
  const TUI_REGISTER_TIMEOUT_MS = 20_000;
129
131
  /** `open -a <terminal>` must answer fast or the incubation falls back. */
130
132
  const TERMINAL_OPEN_TIMEOUT_MS = 3_000;
131
- /** Set ZCODE_ACP_HUB_TERMINAL=0 to keep remote session-create headless. */
132
- const TERMINAL_GATE_ENV = "ZCODE_ACP_HUB_TERMINAL";
133
133
  /** Single-quote for sh: ' → '\'' . */
134
134
  function shQuote(s) {
135
135
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -141,11 +141,9 @@ function shQuote(s) {
141
141
  * WezTerm's `start --` runs an alternative program (wezterm.org/cli/start.html);
142
142
  * kitty takes the program as normal positional arguments
143
143
  * (sw.kovidgoyal.net/kitty/invocation); Alacritty and Ghostty support the
144
- * common `-e` flag. The script does its own `cd`, so no per-app cwd flags.
145
- * Warp is deliberately ABSENT — it cannot execute `.command` files (#1917)
146
- * and has no programmatic command execution at all (URI scheme opens dirs
147
- * only; still an open request in #3959/#9083). Hyper is absent for the same
148
- * reason (#3677).
144
+ * common `-e` flag; Warp rides its new_tab URI action (see warpUri above).
145
+ * The script does its own `cd`, so no per-app cwd flags. Hyper is absent — no
146
+ * programmatic command execution at all (vercel/hyper#3677).
149
147
  */
150
148
  const TERMINAL_APP_LAUNCHERS = {
151
149
  terminal: { kind: "openApp", app: "Terminal" },
@@ -156,34 +154,30 @@ const TERMINAL_APP_LAUNCHERS = {
156
154
  kitty: { kind: "openAppArgs", app: "kitty", args: [] },
157
155
  alacritty: { kind: "openAppArgs", app: "Alacritty", args: ["-e"] },
158
156
  ghostty: { kind: "openAppArgs", app: "Ghostty", args: ["-e"] },
157
+ warp: { kind: "warpUri", app: "Warp", scheme: "warp" },
158
+ "warp-preview": { kind: "warpUri", app: "Warp Preview", scheme: "warppreview" },
159
+ warp_preview: { kind: "warpUri", app: "Warp Preview", scheme: "warppreview" },
159
160
  };
160
161
  /**
161
- * Pick how to open the TUI script. The hub is a background daemon — its env
162
- * carries no terminal and macOS has no default-terminal setting — so nothing
163
- * is detected. Priority: the explicit command template (the universal escape
164
- * hatch) → a built-in launcher by app name (aliases are case- and
165
- * `.app`-suffix-insensitive) → plain Terminal.app. Warp gets a special warning
166
- * because it accepts the open but cannot run the script; any other unmatched
162
+ * Pick how to open the TUI script. Preferences arrive pre-merged from
163
+ * `remoteTerminalPrefs` (config file first, env fallback — see config.ts);
164
+ * only the app-name normalization lives here. Priority: the explicit command
165
+ * template (the universal escape hatch) → a built-in launcher by app name
166
+ * (aliases are case- and `.app`-suffix-insensitive) → plain Terminal.app
167
+ * (macOS has no default-terminal setting to detect). Any other unmatched
167
168
  * name passes through to `open -a` unchanged.
168
169
  */
169
- export function resolveTerminalLaunch(env) {
170
- const command = (env.ZCODE_ACP_HUB_TERMINAL_COMMAND ?? "").trim();
171
- if (command)
172
- return { launch: { kind: "shell", command } };
173
- const raw = (env.ZCODE_ACP_HUB_TERMINAL_APP ?? "").trim();
174
- const name = raw.toLowerCase().replace(/\.app$/, "");
170
+ export function resolveTerminalLaunch(env, prefs = remoteTerminalPrefs(env)) {
171
+ if (prefs.command)
172
+ return { launch: { kind: "shell", command: prefs.command } };
173
+ const raw = prefs.app ?? "";
174
+ const name = raw
175
+ .toLowerCase()
176
+ .replace(/\.app$/, "")
177
+ .replace(/\s+/g, "-");
175
178
  const launcher = TERMINAL_APP_LAUNCHERS[name];
176
179
  if (launcher)
177
180
  return { launch: launcher };
178
- if (name === "warp") {
179
- return {
180
- launch: { kind: "openApp", app: raw || "Warp" },
181
- warning: "Warp cannot execute .command scripts (warpdotdev/warp#1917) and has no " +
182
- "programmatic command execution (warpdotdev/warp#3959, #9083) — the window " +
183
- "will idle and session-create falls back to a headless bridge after the " +
184
- "register timeout; set ZCODE_ACP_HUB_TERMINAL_COMMAND to drive Warp another way",
185
- };
186
- }
187
181
  return { launch: { kind: "openApp", app: raw || "Terminal" } };
188
182
  }
189
183
  /**
@@ -196,13 +190,22 @@ export function resolveTerminalLaunch(env) {
196
190
  */
197
191
  export function terminalTuiScript(cwd, cliJs, env) {
198
192
  const exports = Object.keys(env)
199
- .filter((k) => k.startsWith("ZCODE_ACP_"))
193
+ // DSH_TUI_AUTOPROMPT is the one non-ZCODE_ACP_* passenger: the boot-resume
194
+ // banner handshake (martty reads it at its own process start).
195
+ .filter((k) => k.startsWith("ZCODE_ACP_") || k === "DSH_TUI_AUTOPROMPT")
200
196
  .map((k) => `export ${k}=${shQuote(String(env[k]))}`);
201
197
  return [
202
198
  "#!/bin/sh",
203
199
  `# Hub-incubated TUI session (ADR-0016): closing this window ends the bridge.`,
204
200
  `cd ${shQuote(cwd)} || exit 1`,
205
201
  ...exports,
202
+ // OSC 0 names the tab after the conversation — without it terminals show
203
+ // the running process ("node"). printf reads the \033/\007 escapes from
204
+ // the format string; %s keeps the value itself shell-safe. martty never
205
+ // sets a terminal title, so this survives until the window closes.
206
+ ...(env.ZCODE_ACP_TAB_TITLE !== undefined
207
+ ? [`printf '\\033]0;%s\\007' "$ZCODE_ACP_TAB_TITLE"`]
208
+ : []),
206
209
  `exec ${shQuote(process.execPath)} ${shQuote(cliJs)}`,
207
210
  "",
208
211
  ].join("\n");
@@ -222,8 +225,12 @@ export function terminalTuiScript(cwd, cliJs, env) {
222
225
  async function spawnTerminalTui(opts) {
223
226
  if (process.platform !== "darwin")
224
227
  return null;
225
- const gate = (process.env[TERMINAL_GATE_ENV] ?? "").trim().toLowerCase();
226
- if (["0", "false", "off"].includes(gate))
228
+ // Terminal prefs are read LIVE here (config file first, env fallback): the
229
+ // hub outlives the shells that configured it, and its birth env rotates
230
+ // between GUI editors and interactive shells — only the file is stable.
231
+ // Set remote.terminal.enabled=false in ~/.config/zcode-acp/config.json (or
232
+ // ZCODE_ACP_HUB_TERMINAL=0) to keep remote session-create headless.
233
+ if (!remoteTerminalPrefs(process.env).enabled)
227
234
  return null;
228
235
  const cliJs = fileURLToPath(new URL("../cli.js", import.meta.url));
229
236
  const script = path.join(mkdtempSync(path.join(tmpdir(), "zcode-acp-term-")), "tui.command");
@@ -242,6 +249,16 @@ async function spawnTerminalTui(opts) {
242
249
  else if (launch.kind === "openApp") {
243
250
  argv = ["open", "-a", launch.app, script];
244
251
  }
252
+ else if (launch.kind === "warpUri") {
253
+ // new_tab = Warp's default open mode (like Cmd+T: a tab in the focused
254
+ // window; Warp opens a window first if none exists).
255
+ argv = [
256
+ "open",
257
+ "-a",
258
+ launch.app,
259
+ `${launch.scheme}://action/new_tab?path=${encodeURIComponent(script)}`,
260
+ ];
261
+ }
245
262
  else {
246
263
  argv = ["open", "-na", launch.app, "--args", ...launch.args, "/bin/sh", script];
247
264
  }
@@ -371,6 +388,36 @@ const MAX_SESSION_LIST_BYTES = 4 * 1024 * 1024;
371
388
  * gives its own query 15s).
372
389
  */
373
390
  const SESSION_LIST_TIMEOUT_MS = 12_000;
391
+ /** Best-effort budget for the resume tab-title lookup — the window must
392
+ * never wait on a slow bridge (the 12s list budget is for listings, not
393
+ * for cosmetics). */
394
+ const TITLE_LOOKUP_BUDGET_MS = 2_000;
395
+ /**
396
+ * Tab-title sanitizer: the value is printf'd into the terminal as an OSC
397
+ * payload, so control characters (an ESC inside a model-generated summary
398
+ * would inject terminal sequences) become spaces, whitespace collapses, and
399
+ * the length caps at what a tab can usefully show. Empty/non-string →
400
+ * undefined (the caller falls back to the project name).
401
+ */
402
+ export function sanitizeTabTitle(v) {
403
+ if (typeof v !== "string")
404
+ return undefined;
405
+ const clean = v
406
+ .replace(/[\p{Cc}]/gu, " ")
407
+ .replace(/\s+/g, " ")
408
+ .trim()
409
+ .slice(0, 80);
410
+ return clean.length > 0 ? clean : undefined;
411
+ }
412
+ /** Resolve with undefined after `ms` — for best-effort lookups that must not
413
+ * stall their caller; the losing promise settles into the void (its own
414
+ * rejection is the caller's `.catch`, never unhandled). */
415
+ function withTimeout(p, ms) {
416
+ const timeout = new Promise((resolve) => {
417
+ setTimeout(() => resolve(undefined), ms).unref?.();
418
+ });
419
+ return Promise.race([p, timeout]);
420
+ }
374
421
  /**
375
422
  * Fetch a bridge's GET /sessions payload (ADR-0015), forwarding the
376
423
  * pagination query ("?limit=..&before=.."). Resolves with the parsed JSON;
@@ -527,7 +574,7 @@ export function startHub(options) {
527
574
  * requested session (ADR-0017), "serve" the detached headless bridge
528
575
  * (background listing, ADR-0015).
529
576
  */
530
- const incubateServe = async (workspacePath, kind, sessionId) => {
577
+ const incubateServe = async (workspacePath, kind, sessionId, tabTitle) => {
531
578
  // Async spawn failures (ENOENT — the dir vanished between the whitelist
532
579
  // check and the spawn) arrive as an 'error' event with exitCode still
533
580
  // null; without this flag the poll burns the full budget.
@@ -562,6 +609,19 @@ export function startHub(options) {
562
609
  // TUI boots into it. The detached serve fallback ignores it; the
563
610
  // client attaches to the serve bridge and session/loads there instead.
564
611
  env.ZCODE_ACP_RESUME_SESSION = sessionId;
612
+ // Banner handshake (see BOOT_RESUME_TRIGGER): martty auto-submits this
613
+ // text at boot, which drops its welcome banner and reveals the
614
+ // boot-replayed history without waiting for the user's first message.
615
+ // Must ride the env verbatim — martty reads it once at process start.
616
+ env.DSH_TUI_AUTOPROMPT = BOOT_RESUME_TRIGGER;
617
+ }
618
+ if (kind !== "serve") {
619
+ // Tab title (both visible-terminal kinds): a resume carries the
620
+ // conversation's title when the hub resolved it; session-create and a
621
+ // failed lookup fall back to the project name. Rides the env —
622
+ // terminalTuiScript turns it into an OSC 0 before exec'ing the CLI
623
+ // (terminals otherwise name the tab after the process, "node").
624
+ env.ZCODE_ACP_TAB_TITLE = tabTitle ?? path.basename(workspacePath);
565
625
  }
566
626
  try {
567
627
  // Resume shares session-create's visible-terminal surface (and its
@@ -624,14 +684,14 @@ export function startHub(options) {
624
684
  * key carries the session id, so resumes of different sessions each get
625
685
  * their own window.
626
686
  */
627
- const joinIncubation = (workspacePath, kind, sessionId) => {
687
+ const joinIncubation = (workspacePath, kind, sessionId, tabTitle) => {
628
688
  const mapKey = kind === "resume" ? `${workspacePath}\u0000resume\u0000${sessionId ?? ""}` : workspacePath;
629
689
  const inflight = serveIncubations.get(mapKey);
630
690
  if (inflight && (inflight.kind === kind || kind === "serve")) {
631
691
  log(`hub: joining the incubating serve bridge for ${workspacePath}`);
632
692
  return inflight.promise;
633
693
  }
634
- const promise = incubateServe(workspacePath, kind, sessionId);
694
+ const promise = incubateServe(workspacePath, kind, sessionId, tabTitle);
635
695
  serveIncubations.set(mapKey, { kind, promise });
636
696
  // Drop the entry once settled (identity-checked — a newer incubation may
637
697
  // already have replaced it). The catch keeps the DERIVED promise handled;
@@ -969,13 +1029,26 @@ export function startHub(options) {
969
1029
  return;
970
1030
  }
971
1031
  if (resumeSid) {
1032
+ // Tab title, best-effort: the App browsed this conversation through
1033
+ // the serve bridge's /sessions history, which is normally still live
1034
+ // and still holds the title. Every miss (no live instance, id beyond
1035
+ // the page, slow fetch) falls back to the project name inside
1036
+ // incubateServe — the window must never wait on this lookup.
1037
+ let tabTitle;
1038
+ const titleSource = findServeInstance(workspacePath);
1039
+ if (titleSource) {
1040
+ const row = await withTimeout(fetchBridgeSessions(titleSource.port, "?limit=200")
1041
+ .then((payload) => (payload.sessions ?? []).find((s) => s.sessionId === resumeSid))
1042
+ .catch(() => undefined), TITLE_LOOKUP_BUDGET_MS);
1043
+ tabTitle = sanitizeTabTitle(row?.title);
1044
+ }
972
1045
  // Resume never reuses the live serve bridge: the window IS the
973
1046
  // feature, and the answer must be the NEW instance so the attaching
974
1047
  // client and the terminal share one bridge (and one backend process)
975
1048
  // for the session. A bogus id still opens the window — the TUI
976
1049
  // shows the load failure and falls back to a fresh session, the same
977
1050
  // honesty as a window that dies early (ADR-0016 §4).
978
- const incubation = joinIncubation(workspacePath, "resume", resumeSid);
1051
+ const incubation = joinIncubation(workspacePath, "resume", resumeSid, tabTitle);
979
1052
  try {
980
1053
  const out = await incubation;
981
1054
  res.writeHead(200, { "Content-Type": "application/json" });