@co0ontty/wand 4.72.2 → 4.75.0-beta.g1d477a2

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 (101) hide show
  1. package/dist/build-info.json +4 -4
  2. package/dist/claude-sdk-runner.d.ts +2 -3
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +48 -0
  5. package/dist/daemon-connection.d.ts +9 -0
  6. package/dist/daemon-connection.js +86 -0
  7. package/dist/distribution-manager.d.ts +5 -0
  8. package/dist/distribution-manager.js +79 -1
  9. package/dist/git-quick-commit.js +12 -0
  10. package/dist/message-truncator.js +9 -3
  11. package/dist/models.d.ts +39 -2
  12. package/dist/models.js +302 -9
  13. package/dist/native/darwin-arm64/wand-render +0 -0
  14. package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
  15. package/dist/native/darwin-arm64/wand-render.version +1 -0
  16. package/dist/native/darwin-arm64/wand-structured-renderd +0 -0
  17. package/dist/native/darwin-arm64/wand-structured-renderd.sha256 +1 -0
  18. package/dist/native/darwin-arm64/wand-structured-renderd.version +1 -0
  19. package/dist/native/darwin-x64/wand-render +0 -0
  20. package/dist/native/darwin-x64/wand-render.sha256 +1 -0
  21. package/dist/native/darwin-x64/wand-render.version +1 -0
  22. package/dist/native/darwin-x64/wand-structured-renderd +0 -0
  23. package/dist/native/darwin-x64/wand-structured-renderd.sha256 +1 -0
  24. package/dist/native/darwin-x64/wand-structured-renderd.version +1 -0
  25. package/dist/native/linux-arm64/wand-render +0 -0
  26. package/dist/native/linux-arm64/wand-render.sha256 +1 -0
  27. package/dist/native/linux-arm64/wand-render.version +1 -0
  28. package/dist/native/linux-arm64/wand-structured-renderd +0 -0
  29. package/dist/native/linux-arm64/wand-structured-renderd.sha256 +1 -0
  30. package/dist/native/linux-arm64/wand-structured-renderd.version +1 -0
  31. package/dist/native/linux-x64/wand-render +0 -0
  32. package/dist/native/linux-x64/wand-render.sha256 +1 -0
  33. package/dist/native/linux-x64/wand-render.version +1 -0
  34. package/dist/native/linux-x64/wand-structured-renderd +0 -0
  35. package/dist/native/linux-x64/wand-structured-renderd.sha256 +1 -0
  36. package/dist/native/linux-x64/wand-structured-renderd.version +1 -0
  37. package/dist/process-manager.d.ts +12 -1
  38. package/dist/process-manager.js +88 -7
  39. package/dist/pty-terminal-state.d.ts +17 -0
  40. package/dist/pty-terminal-state.js +51 -1
  41. package/dist/render-binary.d.ts +61 -0
  42. package/dist/render-binary.js +204 -0
  43. package/dist/render-daemon-client.d.ts +202 -0
  44. package/dist/render-daemon-client.js +1164 -0
  45. package/dist/render-host.d.ts +88 -0
  46. package/dist/render-host.js +440 -0
  47. package/dist/render-protocol.d.ts +128 -0
  48. package/dist/render-protocol.js +99 -0
  49. package/dist/render-structured-client.d.ts +80 -0
  50. package/dist/render-structured-client.js +513 -0
  51. package/dist/render-structured-host.d.ts +31 -0
  52. package/dist/render-structured-host.js +218 -0
  53. package/dist/render-structured-protocol.d.ts +101 -0
  54. package/dist/render-structured-protocol.js +35 -0
  55. package/dist/server-session-routes.js +114 -12
  56. package/dist/server-task-routes.js +55 -6
  57. package/dist/server-update-routes.d.ts +2 -0
  58. package/dist/server-update-routes.js +44 -0
  59. package/dist/server.js +43 -13
  60. package/dist/session-ai-context.d.ts +1 -1
  61. package/dist/session-ai-context.js +15 -4
  62. package/dist/session-logger.d.ts +2 -0
  63. package/dist/session-logger.js +44 -1
  64. package/dist/session-transport.js +1 -1
  65. package/dist/storage.d.ts +2 -1
  66. package/dist/storage.js +14 -5
  67. package/dist/structured-client-protocol.d.ts +3 -1
  68. package/dist/structured-client-protocol.js +85 -25
  69. package/dist/structured-content.d.ts +20 -3
  70. package/dist/structured-content.js +80 -1
  71. package/dist/structured-exec-host.js +7 -1
  72. package/dist/structured-exec-pump.js +6 -2
  73. package/dist/structured-grok-adapter.js +22 -0
  74. package/dist/structured-pi-adapter.js +18 -2
  75. package/dist/structured-provider-common.d.ts +11 -9
  76. package/dist/structured-provider-common.js +60 -18
  77. package/dist/structured-session-manager.d.ts +2 -0
  78. package/dist/structured-session-manager.js +27 -3
  79. package/dist/task-types.d.ts +4 -1
  80. package/dist/terminal-daemon-client.d.ts +9 -2
  81. package/dist/terminal-daemon-client.js +119 -46
  82. package/dist/terminal-daemon-server.js +47 -8
  83. package/dist/terminal-host.d.ts +6 -0
  84. package/dist/terminal-host.js +3 -0
  85. package/dist/tui/commands.js +183 -10
  86. package/dist/tui/ipc-server.js +12 -0
  87. package/dist/types.d.ts +30 -10
  88. package/dist/unix-socket-keepalive.d.ts +3 -0
  89. package/dist/unix-socket-keepalive.js +94 -0
  90. package/dist/wand-task-sync.js +2 -1
  91. package/dist/web-ui/content/scripts.js +79 -73
  92. package/dist/web-ui/content/styles.css +1 -1
  93. package/dist/web-ui/content/tailwind.css +21 -0
  94. package/dist/web-ui/embedded-assets.js +2 -2
  95. package/dist/web-ui/thinking-efforts.d.ts +24 -0
  96. package/dist/web-ui/thinking-efforts.js +81 -0
  97. package/dist/ws-broadcast.d.ts +1 -1
  98. package/dist/ws-broadcast.js +28 -9
  99. package/package.json +8 -3
  100. package/scripts/install-render-binary.js +499 -0
  101. package/scripts/stage-render-binaries.js +626 -0
@@ -0,0 +1,31 @@
1
+ import { RenderStructuredClient } from "./render-structured-client.js";
2
+ import type { StructuredExecHost, StructuredExecProcess, StructuredRunState, StructuredSpawnRequest } from "./structured-exec-host.js";
3
+ type Owner = "legacy" | "rust";
4
+ /** Resolve the separate v2 executable; do not confuse it with PTY v1 wand-render. */
5
+ export declare function resolveStructuredRenderBinary(configPath: string): string | null;
6
+ /**
7
+ * A stable runId can belong to exactly one persistent daemon. Configuration
8
+ * chooses only new runs; existing owners are always resolved by inventories.
9
+ */
10
+ export declare class CompositeStructuredExecHost implements StructuredExecHost {
11
+ private readonly legacy;
12
+ private readonly rust;
13
+ private readonly newOwner;
14
+ readonly persistent = true;
15
+ private readonly owners;
16
+ private readonly starting;
17
+ constructor(legacy: StructuredExecHost | null, rust: StructuredExecHost | null, newOwner: Owner);
18
+ private getHost;
19
+ listRuns(): Promise<StructuredRunState[]>;
20
+ private ownerOf;
21
+ spawnStructured(request: StructuredSpawnRequest): Promise<StructuredExecProcess>;
22
+ attachRun(runId: string): Promise<StructuredRunState | null>;
23
+ adoptRun(runId: string): Promise<StructuredExecProcess | null>;
24
+ forgetRun(runId: string): void;
25
+ }
26
+ /** Default legacy. v2 is adopted even on rollback when it still owns runs. */
27
+ export declare function createUpgradeAwareStructuredHost(configPath: string, legacy: StructuredExecHost | null, processHost?: "legacy" | "rust"): Promise<{
28
+ host: StructuredExecHost | undefined;
29
+ rustClient: RenderStructuredClient | null;
30
+ }>;
31
+ export {};
@@ -0,0 +1,218 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, lstatSync, readFileSync, unlinkSync } from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { getErrorMessage } from "./error-utils.js";
7
+ import { currentRenderTriple, isExecutableFile } from "./render-binary.js";
8
+ import { RenderStructuredClient } from "./render-structured-client.js";
9
+ import { structuredRenderPaths } from "./render-structured-protocol.js";
10
+ const READY_TIMEOUT_MS = 5_000;
11
+ /** Resolve the separate v2 executable; do not confuse it with PTY v1 wand-render. */
12
+ export function resolveStructuredRenderBinary(configPath) {
13
+ const explicit = process.env.WAND_STRUCTURED_RENDER_BIN?.trim();
14
+ if (explicit) {
15
+ if (!isExecutableFile(explicit))
16
+ throw new Error("WAND_STRUCTURED_RENDER_BIN is not executable");
17
+ return explicit;
18
+ }
19
+ const binary = process.platform === "win32" ? "wand-structured-renderd.exe" : "wand-structured-renderd";
20
+ const root = path.resolve(import.meta.dirname, "..");
21
+ const choices = [
22
+ path.join(root, "render", "target", "release", binary),
23
+ path.join(root, "render", "target", "debug", binary),
24
+ path.join(path.dirname(configPath), "bin", binary),
25
+ path.join(root, "dist", "native", currentRenderTriple(), binary),
26
+ ...(process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, binary)),
27
+ ];
28
+ return choices.find(isExecutableFile) ?? null;
29
+ }
30
+ function livePid(file) {
31
+ try {
32
+ const pid = Number(readFileSync(file, "utf8").trim());
33
+ if (!Number.isSafeInteger(pid) || pid <= 0)
34
+ return null;
35
+ process.kill(pid, 0);
36
+ return pid;
37
+ }
38
+ catch (error) {
39
+ return error.code === "EPERM" ? -1 : null;
40
+ }
41
+ }
42
+ async function socketIsListening(socketPath) {
43
+ return new Promise((resolve) => {
44
+ const socket = net.createConnection(socketPath);
45
+ let done = false;
46
+ const finish = (listening) => {
47
+ if (done)
48
+ return;
49
+ done = true;
50
+ clearTimeout(timer);
51
+ socket.destroy();
52
+ resolve(listening);
53
+ };
54
+ const timer = setTimeout(() => finish(false), 250);
55
+ socket.once("connect", () => finish(true));
56
+ socket.once("error", () => finish(false));
57
+ });
58
+ }
59
+ async function connectExisting(configPath) {
60
+ const paths = structuredRenderPaths(configPath);
61
+ if (!existsSync(paths.socketPath))
62
+ return null;
63
+ const client = new RenderStructuredClient(configPath);
64
+ try {
65
+ await client.connect();
66
+ return client;
67
+ }
68
+ catch (error) {
69
+ client.disconnect();
70
+ if (await socketIsListening(paths.socketPath) || livePid(paths.pidPath) !== null) {
71
+ throw new Error(`Structured Render endpoint exists but refused adoption: ${getErrorMessage(error)}`);
72
+ }
73
+ // Only remove our own private, unlistened stale socket. Never unlink a
74
+ // foreign path or race a live daemon's startup/credential rotation.
75
+ const stat = lstatSync(paths.socketPath);
76
+ const ownUid = typeof process.getuid !== "function" || stat.uid === process.getuid();
77
+ if (!stat.isSocket() || !ownUid || (stat.mode & 0o777) !== 0o600) {
78
+ throw new Error("Structured Render endpoint is not a private socket; refusing cleanup");
79
+ }
80
+ unlinkSync(paths.socketPath);
81
+ return null;
82
+ }
83
+ }
84
+ async function startV2(configPath) {
85
+ const existing = await connectExisting(configPath);
86
+ if (existing)
87
+ return existing;
88
+ const paths = structuredRenderPaths(configPath);
89
+ if (livePid(paths.pidPath) !== null) {
90
+ const deadline = Date.now() + READY_TIMEOUT_MS;
91
+ while (Date.now() < deadline) {
92
+ const starting = await connectExisting(configPath);
93
+ if (starting)
94
+ return starting;
95
+ await delay(50);
96
+ }
97
+ throw new Error("Structured Render PID is alive but its socket is unavailable; refusing a competing daemon");
98
+ }
99
+ const binary = resolveStructuredRenderBinary(configPath);
100
+ if (!binary)
101
+ throw new Error("structured.processHost=rust requires a v2 binary; build wand-structured-renderd or set WAND_STRUCTURED_RENDER_BIN");
102
+ const child = spawn(binary, ["--config", configPath], {
103
+ detached: true, stdio: "ignore", cwd: process.cwd(), env: process.env,
104
+ });
105
+ child.on("error", (error) => process.stderr.write(`[wand] Structured Render launch failed: ${getErrorMessage(error)}\n`));
106
+ child.unref();
107
+ const deadline = Date.now() + READY_TIMEOUT_MS;
108
+ while (Date.now() < deadline) {
109
+ try {
110
+ const client = await connectExisting(configPath);
111
+ if (client)
112
+ return client;
113
+ }
114
+ catch (error) {
115
+ // A live but incompatible owner must not be replaced or silently downgraded.
116
+ throw error;
117
+ }
118
+ await delay(50);
119
+ }
120
+ if (child.pid) {
121
+ try {
122
+ process.kill(child.pid, "SIGTERM");
123
+ }
124
+ catch { /* child already exited */ }
125
+ }
126
+ throw new Error("Structured Render v2 did not become ready; refusing legacy fallback");
127
+ }
128
+ /**
129
+ * A stable runId can belong to exactly one persistent daemon. Configuration
130
+ * chooses only new runs; existing owners are always resolved by inventories.
131
+ */
132
+ export class CompositeStructuredExecHost {
133
+ legacy;
134
+ rust;
135
+ newOwner;
136
+ persistent = true;
137
+ owners = new Map();
138
+ starting = new Map();
139
+ constructor(legacy, rust, newOwner) {
140
+ this.legacy = legacy;
141
+ this.rust = rust;
142
+ this.newOwner = newOwner;
143
+ if (!legacy && !rust)
144
+ throw new Error("at least one structured daemon is required");
145
+ }
146
+ getHost(owner) {
147
+ return owner === "legacy" ? this.legacy : this.rust;
148
+ }
149
+ async listRuns() {
150
+ const [legacyRuns, rustRuns] = await Promise.all([
151
+ this.legacy?.listRuns() ?? Promise.resolve([]),
152
+ this.rust?.listRuns() ?? Promise.resolve([]),
153
+ ]);
154
+ const legacyIds = new Set(legacyRuns.map((run) => run.runId));
155
+ for (const run of rustRuns) {
156
+ if (legacyIds.has(run.runId))
157
+ throw new Error(`structured run ${run.runId} has competing owners; refusing adoption`);
158
+ }
159
+ for (const run of legacyRuns)
160
+ this.owners.set(run.runId, "legacy");
161
+ for (const run of rustRuns)
162
+ this.owners.set(run.runId, "rust");
163
+ return [...legacyRuns, ...rustRuns];
164
+ }
165
+ async ownerOf(runId) {
166
+ const cached = this.owners.get(runId);
167
+ if (cached)
168
+ return cached;
169
+ await this.listRuns();
170
+ return this.owners.get(runId) ?? null;
171
+ }
172
+ async spawnStructured(request) {
173
+ const pending = this.starting.get(request.runId);
174
+ if (pending)
175
+ return pending;
176
+ const attempt = (async () => {
177
+ const owner = (await this.ownerOf(request.runId)) ?? this.newOwner;
178
+ const host = this.getHost(owner);
179
+ if (!host)
180
+ throw new Error(`structured owner ${owner} is unavailable; no fallback across owners`);
181
+ const handle = await host.spawnStructured(request);
182
+ this.owners.set(request.runId, owner);
183
+ return handle;
184
+ })();
185
+ this.starting.set(request.runId, attempt);
186
+ try {
187
+ return await attempt;
188
+ }
189
+ finally {
190
+ if (this.starting.get(request.runId) === attempt)
191
+ this.starting.delete(request.runId);
192
+ }
193
+ }
194
+ async attachRun(runId) {
195
+ const owner = await this.ownerOf(runId);
196
+ return owner ? this.getHost(owner)?.attachRun(runId) ?? null : null;
197
+ }
198
+ async adoptRun(runId) {
199
+ const owner = await this.ownerOf(runId);
200
+ return owner ? this.getHost(owner)?.adoptRun(runId) ?? null : null;
201
+ }
202
+ forgetRun(runId) {
203
+ const owner = this.owners.get(runId);
204
+ this.owners.delete(runId);
205
+ if (owner)
206
+ this.getHost(owner)?.forgetRun(runId);
207
+ }
208
+ }
209
+ /** Default legacy. v2 is adopted even on rollback when it still owns runs. */
210
+ export async function createUpgradeAwareStructuredHost(configPath, legacy, processHost = "legacy") {
211
+ if (process.env.WAND_TEST_MODE === "1" || process.env.NODE_TEST_CONTEXT) {
212
+ return { host: legacy ?? undefined, rustClient: null };
213
+ }
214
+ const rustClient = processHost === "rust" ? await startV2(configPath) : await connectExisting(configPath);
215
+ if (!rustClient && !legacy)
216
+ return { host: undefined, rustClient: null };
217
+ return { host: new CompositeStructuredExecHost(legacy, rustClient, processHost), rustClient };
218
+ }
@@ -0,0 +1,101 @@
1
+ /** Separate structured process daemon. v1 PTY socket and wire format are unchanged. */
2
+ export declare const STRUCTURED_RENDER_PROTOCOL_VERSION = 2;
3
+ export declare const STRUCTURED_RENDER_RUN_LOG_MAX_BYTES: number;
4
+ export declare const STRUCTURED_RENDER_REPLAY_PAGE_MAX_BYTES: number;
5
+ /** Envelope and length-prefixed JSON framing are identical to Render v1. */
6
+ export { encodeRenderFrame, decodeRenderFrames } from "./render-protocol.js";
7
+ export type { RenderResponse, RenderErrorCode } from "./render-protocol.js";
8
+ export interface StructuredRenderRunState {
9
+ runId: string;
10
+ incarnationId: string;
11
+ pid: number;
12
+ status: "running" | "exited";
13
+ exitCode: number | null;
14
+ signal: number | null;
15
+ stdoutSeq: number;
16
+ stderrSeq: number;
17
+ stdoutTruncated: boolean;
18
+ stderrTruncated: boolean;
19
+ }
20
+ export interface StructuredRenderSpawnParams {
21
+ runId: string;
22
+ file: string;
23
+ args: string[];
24
+ cwd: string;
25
+ env: Record<string, string>;
26
+ stdinData?: string;
27
+ }
28
+ export interface StructuredRenderSpawnResult {
29
+ state: StructuredRenderRunState;
30
+ isNew: boolean;
31
+ }
32
+ /** Inventory deliberately carries no raw bytes, prompt, argv, cwd or env. */
33
+ export interface StructuredRenderListResult {
34
+ runs: StructuredRenderRunState[];
35
+ }
36
+ export interface StructuredRenderAttachParams {
37
+ runId: string;
38
+ afterStdoutSeq?: number;
39
+ afterStderrSeq?: number;
40
+ maxBytes?: number;
41
+ }
42
+ export interface StructuredRenderReplayStream {
43
+ chunks: {
44
+ seq: number;
45
+ data: string;
46
+ }[];
47
+ nextSeq: number;
48
+ complete: boolean;
49
+ resetRequired: boolean;
50
+ }
51
+ export interface StructuredRenderAttachResult {
52
+ state: StructuredRenderRunState;
53
+ stdout: StructuredRenderReplayStream;
54
+ stderr: StructuredRenderReplayStream;
55
+ }
56
+ export interface StructuredRenderHelloResult {
57
+ version: string;
58
+ protocolVersion: number;
59
+ pid: number;
60
+ startedAt: string;
61
+ runs: number;
62
+ }
63
+ export interface StructuredRenderStatsResult {
64
+ runs: number;
65
+ runningRuns: number;
66
+ retainedLogBytes: number;
67
+ rssBytes: number;
68
+ }
69
+ export type StructuredRenderEvent = {
70
+ event: "stream";
71
+ runId: string;
72
+ incarnationId: string;
73
+ stream: "stdout" | "stderr";
74
+ seq: number;
75
+ data: string;
76
+ } | {
77
+ event: "exit";
78
+ runId: string;
79
+ incarnationId: string;
80
+ exitCode: number | null;
81
+ signal: number | null;
82
+ } | {
83
+ event: "reconcile";
84
+ runIds: string[];
85
+ };
86
+ export type StructuredRenderMethod = "hello" | "ping" | "list" | "spawn" | "attach" | "interrupt" | "forget" | "stats" | "shutdown";
87
+ export interface StructuredRenderRequest {
88
+ id: number;
89
+ token: string;
90
+ protocolVersion: number;
91
+ method: StructuredRenderMethod;
92
+ params?: Record<string, unknown>;
93
+ }
94
+ export interface StructuredRenderPaths {
95
+ socketPath: string;
96
+ tokenPath: string;
97
+ pidPath: string;
98
+ metaPath: string;
99
+ }
100
+ /** Mirrors Rust normalized_absolute() and config_suffix(), in an independent namespace. */
101
+ export declare function structuredRenderPaths(configPath: string): StructuredRenderPaths;
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /** Separate structured process daemon. v1 PTY socket and wire format are unchanged. */
6
+ export const STRUCTURED_RENDER_PROTOCOL_VERSION = 2;
7
+ export const STRUCTURED_RENDER_RUN_LOG_MAX_BYTES = 8 * 1024 * 1024;
8
+ export const STRUCTURED_RENDER_REPLAY_PAGE_MAX_BYTES = 512 * 1024;
9
+ /** Envelope and length-prefixed JSON framing are identical to Render v1. */
10
+ export { encodeRenderFrame, decodeRenderFrames } from "./render-protocol.js";
11
+ /** Mirrors Rust normalized_absolute() and config_suffix(), in an independent namespace. */
12
+ export function structuredRenderPaths(configPath) {
13
+ const absolute = path.resolve(configPath);
14
+ let resolved = absolute;
15
+ try {
16
+ resolved = realpathSync(absolute);
17
+ }
18
+ catch { /* config may not exist yet */ }
19
+ const suffix = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
20
+ const dir = path.dirname(resolved);
21
+ const uid = (() => {
22
+ try {
23
+ return os.userInfo().uid;
24
+ }
25
+ catch {
26
+ return 0;
27
+ }
28
+ })();
29
+ return {
30
+ socketPath: path.join("/tmp", `wand-structured-render-${uid}-${suffix}.sock`),
31
+ tokenPath: path.join(dir, `.structured-render-${suffix}.token`),
32
+ pidPath: path.join(dir, `.structured-render-${suffix}.pid`),
33
+ metaPath: path.join(dir, `.structured-render-${suffix}.json`),
34
+ };
35
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { SessionInputError } from "./process-manager.js";
2
+ import { PtyInputDeliveryError, SessionInputError } from "./process-manager.js";
3
3
  import { getDefaultModelForProvider, isExecutionMode } from "./config.js";
4
4
  import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
5
5
  import { toSessionDetailDTO, toSessionListItemDTO } from "./session-transport.js";
@@ -17,6 +17,7 @@ import { sendRouteError } from "./server-request.js";
17
17
  import { registerStructuredResumeRoutes } from "./server-resume-routes.js";
18
18
  import { addHiddenSessionIds, removeHiddenSessionIds } from "./session-registry.js";
19
19
  import { enrichStructuredMessages, WAND_PROTOCOL_VERSION } from "./structured-client-protocol.js";
20
+ import { asRecord, isStructuredImagePart } from "./structured-content.js";
20
21
  import { syncWorkspaceTaskToBoard } from "./wand-task-sync.js";
21
22
  import { buildDirectoryTree, normalizeSessionDirectory, } from "./session-directory-tree.js";
22
23
  import { projectCwdForSession, renameWorkspaceDirectory, resolveWorkspaceIdForNewSession, } from "./workspace-binding.js";
@@ -64,6 +65,17 @@ export function parseSessionCreationOrigin(body) {
64
65
  };
65
66
  }
66
67
  function getInputErrorResponse(error, sessionId) {
68
+ if (error instanceof PtyInputDeliveryError) {
69
+ return {
70
+ statusCode: 503,
71
+ payload: {
72
+ error: "PTY 输入结果未获确认;请先检查终端输出,不要直接重复提交。",
73
+ errorCode: "INPUT_DELIVERY_UNCONFIRMED",
74
+ sessionId,
75
+ sessionStatus: null,
76
+ },
77
+ };
78
+ }
67
79
  if (error instanceof SessionInputError) {
68
80
  const statusCode = error.code === "SESSION_NOT_FOUND" ? 404 : 409;
69
81
  return {
@@ -351,7 +363,7 @@ function isMergeActionAllowed(snapshot) {
351
363
  }
352
364
  export function registerSessionRoutes(app, processes, structured, storage, defaultMode, config, sessions, onSessionCreated) {
353
365
  const sessionResponseDTO = (snapshot) => {
354
- const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {});
366
+ const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {});
355
367
  return toSessionDetailDTO(snapshot, {
356
368
  messages: windowed.messages,
357
369
  messageOffset: windowed.messageOffset,
@@ -565,7 +577,15 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
565
577
  res.status(404).json({ error: "未找到该结构化会话。" });
566
578
  return;
567
579
  }
568
- res.json({ id: snapshot.id, messages: snapshot.messages ?? [] });
580
+ const all = snapshot.messages ?? [];
581
+ const offset = parseBoundedInteger(req.query.offset, Math.max(0, all.length - 40), 0, all.length);
582
+ const limit = parseBoundedInteger(req.query.limit, 40, 1, 200);
583
+ res.json({
584
+ id: snapshot.id,
585
+ messages: truncateMessagesForTransport(all.slice(offset, offset + limit), config.cardDefaults ?? {}),
586
+ offset,
587
+ total: all.length,
588
+ });
569
589
  });
570
590
  app.post("/api/structured-sessions/:id/messages", asyncRoute(async (req, res) => {
571
591
  const input = String(req.body?.input ?? "");
@@ -620,6 +640,19 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
620
640
  sendRouteError(res, error, "无法调整排队顺序。");
621
641
  }
622
642
  });
643
+ app.patch("/api/structured-sessions/:id/queued/:index", (req, res) => {
644
+ const { expectedText, text } = req.body ?? {};
645
+ if (typeof expectedText !== "string" || typeof text !== "string" || !text.trim()) {
646
+ res.status(400).json({ error: "排队消息不能为空,且必须提供原文。" });
647
+ return;
648
+ }
649
+ try {
650
+ res.json(sessionResponseDTO(structured.editQueuedMessage(req.params.id, Number(req.params.index), expectedText, text)));
651
+ }
652
+ catch (error) {
653
+ sendRouteError(res, error, "无法编辑排队消息。");
654
+ }
655
+ });
623
656
  app.delete("/api/structured-sessions/:id/queued/:index", (req, res) => {
624
657
  const index = Number(req.params.index);
625
658
  if (!Number.isFinite(index)) {
@@ -688,6 +721,41 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
688
721
  }
689
722
  res.status(404).json({ error: "未找到该工具结果。" });
690
723
  });
724
+ // ── Inline tool image endpoint ──
725
+ // tool_result 里内联的 base64 图片在传输层被改写为这个 URL(避免把整张图的 base64
726
+ // 塞进每次快照 / WS 帧)。各端用同一套带鉴权的取图通道按需加载。
727
+ app.get("/api/sessions/:id/tool-images/:toolUseId/:index", (req, res) => {
728
+ const snapshot = sessions.get(req.params.id);
729
+ if (!snapshot) {
730
+ res.status(404).json({ error: "未找到该会话。" });
731
+ return;
732
+ }
733
+ const toolUseId = req.params.toolUseId;
734
+ const index = Number.parseInt(req.params.index, 10);
735
+ if (!Number.isFinite(index) || index < 0) {
736
+ res.status(400).json({ error: "无效的图片序号。" });
737
+ return;
738
+ }
739
+ for (const turn of snapshot.messages ?? []) {
740
+ for (const block of turn.content) {
741
+ if (block.type !== "tool_result" || block.tool_use_id !== toolUseId || !Array.isArray(block.content))
742
+ continue;
743
+ const part = block.content.filter(isStructuredImagePart)[index];
744
+ const source = asRecord(part?.source);
745
+ const data = typeof source?.data === "string" ? source.data : undefined;
746
+ if (source?.type !== "base64" || !data) {
747
+ res.status(404).json({ error: "未找到该图片。" });
748
+ return;
749
+ }
750
+ res.setHeader("Content-Type", typeof source.media_type === "string" ? source.media_type : "image/png");
751
+ res.setHeader("Cache-Control", "private, max-age=3600");
752
+ res.setHeader("X-Content-Type-Options", "nosniff");
753
+ res.send(Buffer.from(data, "base64"));
754
+ return;
755
+ }
756
+ }
757
+ res.status(404).json({ error: "未找到该工具结果。" });
758
+ });
691
759
  app.post("/api/sessions/:id/worktree/merge/check", asyncRoute(async (req, res) => {
692
760
  try {
693
761
  const current = requireWorktreeSession(sessions.getLatest(req.params.id));
@@ -1001,6 +1069,25 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1001
1069
  }
1002
1070
  res.json({ ok: true, deleted, failed });
1003
1071
  });
1072
+ app.get("/api/sessions/:id/pty-history", asyncRoute(async (req, res) => {
1073
+ const snapshot = sessions.get(req.params.id);
1074
+ if (!snapshot || (snapshot.sessionKind ?? "pty") !== "pty") {
1075
+ res.status(404).json({ error: "未找到 PTY 会话。" });
1076
+ return;
1077
+ }
1078
+ const before = Number(req.query.before);
1079
+ const revision = Number(req.query.revision);
1080
+ if (!Number.isSafeInteger(before) || !Number.isSafeInteger(revision) || before < 1 || revision < 0) {
1081
+ res.status(400).json({ error: "无效的终端历史游标。" });
1082
+ return;
1083
+ }
1084
+ const page = await processes.getTerminalHistoryPage(snapshot.id, before, revision);
1085
+ if (!page) {
1086
+ res.status(409).json({ error: "终端历史已变化,请重新同步。" });
1087
+ return;
1088
+ }
1089
+ res.json(page);
1090
+ }));
1004
1091
  app.get("/api/sessions/:id", (req, res) => {
1005
1092
  const snapshot = sessions.get(req.params.id);
1006
1093
  if (!snapshot) {
@@ -1011,12 +1098,12 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1011
1098
  ? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
1012
1099
  : snapshot.output;
1013
1100
  if (req.query.format === "chat") {
1014
- // 客户端带 blockBudget(iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
1015
- // 根治「单条 turn 上百块/1MB」的长任务打开慢。Web/Android 不带该参数,走原 turn 级窗口。
1101
+ // 客户端带 blockBudget(Web/iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
1102
+ // 根治「单条 turn 上百块/1MB」的长任务打开慢;未启用的客户端保持 turn 级窗口。
1016
1103
  const rawBudget = req.query.blockBudget;
1017
1104
  if (typeof rawBudget === "string" && /^\d+$/.test(rawBudget) && Number(rawBudget) > 0) {
1018
1105
  const blockBudget = parseBoundedInteger(rawBudget, 1, 1, 2_000);
1019
- const windowed = blockWindowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {}, blockBudget);
1106
+ const windowed = blockWindowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {}, blockBudget);
1020
1107
  res.json(toSessionDetailDTO(snapshot, {
1021
1108
  output: transcriptOutput,
1022
1109
  messages: windowed.messages,
@@ -1028,7 +1115,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1028
1115
  return;
1029
1116
  }
1030
1117
  // 与 WS init 对齐:只回最近一窗 turn + offset/total,更早的走 /messages 翻页。
1031
- const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? []), config.cardDefaults ?? {});
1118
+ const windowed = windowMessagesForTransport(enrichStructuredMessages(snapshot.messages ?? [], snapshot.id), config.cardDefaults ?? {});
1032
1119
  res.json(toSessionDetailDTO(snapshot, {
1033
1120
  output: transcriptOutput,
1034
1121
  messages: windowed.messages,
@@ -1048,8 +1135,20 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1048
1135
  res.status(404).json({ error: "未找到该会话,可能已被删除。" });
1049
1136
  return;
1050
1137
  }
1051
- const all = enrichStructuredMessages(snapshot.messages ?? []);
1138
+ const all = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
1052
1139
  const total = all.length;
1140
+ // Web history: fetch at most one block window *before* the currently
1141
+ // displayed turn. A 40-turn page can contain a single multi-MB assistant
1142
+ // turn, defeating the initial block budget entirely.
1143
+ if (typeof req.query.before === "string" && /^\d+$/.test(req.query.before)
1144
+ && typeof req.query.blockBudget === "string" && /^\d+$/.test(req.query.blockBudget)) {
1145
+ const before = parseBoundedInteger(req.query.before, total, 0, total);
1146
+ const budget = parseBoundedInteger(req.query.blockBudget, 60, 1, 200);
1147
+ const windowed = blockWindowMessagesForTransport(all.slice(0, before), config.cardDefaults ?? {}, budget);
1148
+ res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, ...windowed,
1149
+ offset: windowed.messageOffset, total });
1150
+ return;
1151
+ }
1053
1152
  // 块级翻页(iOS):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
1054
1153
  // 取该 turn 的 [start, blockOffset) 段(start = max(0, blockOffset - blockLimit))。
1055
1154
  const rawTurn = parseInt(String(req.query.turn ?? ""), 10);
@@ -1069,7 +1168,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1069
1168
  const blocks = enrichStructuredMessages([{
1070
1169
  ...turn,
1071
1170
  content: sliceTurnBlocksForTransport(turn, blockStart, blockEnd, config.cardDefaults ?? {}),
1072
- }])[0].content;
1171
+ }], snapshot.id)[0].content;
1073
1172
  res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, turnIndex, blocks, blockOffset: blockStart, blockTotal });
1074
1173
  return;
1075
1174
  }
@@ -1078,7 +1177,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1078
1177
  const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 40, 1), 200);
1079
1178
  const offset = Math.min(Math.max(Number.isFinite(rawOffset) ? rawOffset : 0, 0), total);
1080
1179
  const end = Math.min(offset + limit, total);
1081
- const slice = enrichStructuredMessages(truncateMessagesForTransport(all.slice(offset, end), config.cardDefaults ?? {}));
1180
+ const slice = enrichStructuredMessages(truncateMessagesForTransport(all.slice(offset, end), config.cardDefaults ?? {}), snapshot.id);
1082
1181
  res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, messages: slice, offset, total });
1083
1182
  });
1084
1183
  app.post("/api/sessions/:id/resume", asyncRoute(async (req, res) => {
@@ -1207,14 +1306,17 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
1207
1306
  const autoResumeInput = getAutoResumeInitialInput(existingSession, input, view, shortcutKey);
1208
1307
  if (autoResumeInput !== null && canAutoResumePtyForInput(existingSession, autoResumeInput)) {
1209
1308
  const snapshot = await startResumedPtySession(processes, storage, existingSession, sessionId, defaultMode, {}, autoResumeInput);
1309
+ // Resume input waits for the provider prompt and is deliberately queued after start.
1310
+ // This branch reports a pending delivery, unlike an already-running PTY write.
1311
+ res.setHeader("X-Wand-Input-Delivery", "pending");
1210
1312
  if (body.responseMode === "accepted") {
1211
- res.status(202).json({ accepted: true });
1313
+ res.status(202).json({ accepted: true, deliveryConfirmed: false });
1212
1314
  return;
1213
1315
  }
1214
1316
  res.json(sessionResponseDTO(snapshot));
1215
1317
  return;
1216
1318
  }
1217
- const snapshot = processes.sendInput(sessionId, input, view, shortcutKey);
1319
+ const snapshot = await processes.sendInputConfirmed(sessionId, input, view, shortcutKey);
1218
1320
  if (body.responseMode === "accepted") {
1219
1321
  res.status(202).json({ accepted: true });
1220
1322
  return;