wave-code 1.3.0 → 1.4.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.
Files changed (42) hide show
  1. package/dist/bundle/wave.mjs +1275 -895
  2. package/package.json +4 -2
  3. package/src/commands/update.ts +62 -22
  4. package/src/components/BackgroundTaskManager.tsx +76 -54
  5. package/src/components/ConfirmationDetails.tsx +17 -3
  6. package/src/components/InputBox.tsx +2 -0
  7. package/src/components/MessageBlockItem.tsx +0 -6
  8. package/src/components/WorkflowManager.tsx +38 -138
  9. package/src/components/WorkflowRunDetail.tsx +193 -0
  10. package/src/constants/commands.ts +6 -0
  11. package/src/contexts/useChat.tsx +359 -127
  12. package/src/daemon/commands.ts +13 -4
  13. package/src/daemon-cli.ts +6 -1
  14. package/src/hooks/useInputManager.ts +4 -0
  15. package/src/hooks/useRemoteControl.ts +324 -0
  16. package/src/index.ts +35 -0
  17. package/src/managers/inputHandlers.ts +2 -0
  18. package/src/managers/inputReducer.ts +1 -0
  19. package/src/print-cli.ts +5 -0
  20. package/src/reducers/workflowManagerReducer.ts +21 -2
  21. package/src/remote/frameBudget.ts +45 -0
  22. package/src/remote/hostIdentity.ts +128 -0
  23. package/src/remote/messageWindow.ts +97 -0
  24. package/src/remote/notificationCoalescer.ts +174 -0
  25. package/src/remote/relayClient.ts +668 -0
  26. package/src/remote/remoteControlCliHost.ts +391 -0
  27. package/src/remote/remoteControlCommands.ts +197 -0
  28. package/src/remote/remoteControlConfig.ts +334 -0
  29. package/src/remote/remoteControlService.ts +799 -0
  30. package/src/remote/remoteControlSink.ts +77 -0
  31. package/src/remote/remoteNotifications.ts +194 -0
  32. package/src/stdio/agentBridge.ts +608 -74
  33. package/src/stdio/daemonServer.ts +30 -0
  34. package/src/stdio/jsonRpcConnection.ts +11 -2
  35. package/src/stdio/protocol.ts +14 -115
  36. package/src/stdio/stdioServer.ts +33 -2
  37. package/src/stdio-cli.ts +11 -2
  38. package/src/utils/gitCredentialHelper.ts +106 -0
  39. package/src/utils/logger.ts +5 -2
  40. package/src/utils/rewindCheckpoints.ts +2 -4
  41. package/src/utils/workspaceDiff.ts +620 -0
  42. package/src/utils/worktree.ts +97 -0
@@ -18,10 +18,19 @@ import net from "net";
18
18
  import * as fs from "fs";
19
19
  import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
20
20
  import { JsonRpcConnection } from "./jsonRpcConnection.js";
21
+ import type { RemoteControlSink } from "../remote/remoteControlSink.js";
21
22
 
22
23
  export interface DaemonServerOptions {
23
24
  socketPath: string;
24
25
  bridgeOptions?: AgentBridgeOptions;
26
+ /**
27
+ * Built with the bridge (which the daemon owns privately) so the sink can
28
+ * route inbound phone requests back through it. Its notifications are wired
29
+ * up here; connecting is the user's call, not ours. Unset means remote
30
+ * control is off for this daemon — the `remoteControl*` RPCs then report
31
+ * exactly that through the bridge's own error.
32
+ */
33
+ createRemoteSink?: (bridge: AgentBridge) => RemoteControlSink;
25
34
  }
26
35
 
27
36
  export class DaemonServer {
@@ -30,11 +39,17 @@ export class DaemonServer {
30
39
  private bridge: AgentBridge;
31
40
  private connections = new Set<JsonRpcConnection>();
32
41
  private sockets = new Set<net.Socket>();
42
+ private remoteSink: RemoteControlSink | undefined;
33
43
 
34
44
  constructor(options: DaemonServerOptions) {
35
45
  this.socketPath = options.socketPath;
46
+ let remoteSink: RemoteControlSink | undefined;
36
47
  this.bridge = new AgentBridge({
37
48
  ...options.bridgeOptions,
49
+ // The sink is handed the bridge it must route phone requests back
50
+ // through, so it can only be built after it; reading it lazily keeps the
51
+ // two from having to be constructed in a circle.
52
+ remoteControl: () => remoteSink,
38
53
  // Notifications go to every attached client; a fully detached daemon
39
54
  // has none, and the write is dropped silently (the attach snapshot
40
55
  // re-syncs state on reconnect).
@@ -42,11 +57,20 @@ export class DaemonServer {
42
57
  for (const conn of this.connections) {
43
58
  conn.sendNotification(method, params, sessionId);
44
59
  }
60
+ // The phone must never be able to take the local clients down with
61
+ // it, so a remote-path failure is contained here rather than thrown.
62
+ try {
63
+ this.remoteSink?.sendNotification(method, params, sessionId);
64
+ } catch {
65
+ // Dropped frame; the phone re-syncs on reconnect.
66
+ }
45
67
  },
46
68
  // The `shutdown` RPC (wave daemon stop/restart) destroys every session in
47
69
  // the bridge, then hands off here to tear the process down.
48
70
  onShutdownRequest: () => this.shutdown(),
49
71
  });
72
+ remoteSink = options.createRemoteSink?.(this.bridge);
73
+ this.remoteSink = remoteSink;
50
74
  this.server = net.createServer((socket) => {
51
75
  const conn = new JsonRpcConnection(socket, socket, this.bridge);
52
76
  this.connections.add(conn);
@@ -124,6 +148,8 @@ export class DaemonServer {
124
148
 
125
149
  stop(): Promise<void> {
126
150
  return new Promise((resolve) => {
151
+ this.remoteSink?.stop();
152
+ this.remoteSink = undefined;
127
153
  const server = this.server;
128
154
  if (!server) return resolve();
129
155
  this.server = undefined;
@@ -140,6 +166,10 @@ export class DaemonServer {
140
166
  * teardown.
141
167
  */
142
168
  private shutdown(): void {
169
+ // Drop the relay link explicitly: the process is about to exit, and leaving
170
+ // the socket to the OS would have the relay hold a dead subscription.
171
+ this.remoteSink?.stop();
172
+ this.remoteSink = undefined;
143
173
  for (const socket of this.sockets) socket.destroy();
144
174
  this.sockets.clear();
145
175
  this.server?.close();
@@ -12,9 +12,11 @@ import readline from "readline";
12
12
  import type { Readable, Writable } from "stream";
13
13
  import { AgentBridge } from "./agentBridge.js";
14
14
  import {
15
+ type ClientNotificationMethod,
15
16
  type JsonRpcRequest,
16
17
  type JsonRpcResponse,
17
18
  type JsonRpcNotification,
19
+ type RequestMethod,
18
20
  PARSE_ERROR,
19
21
  INVALID_REQUEST,
20
22
  INTERNAL_ERROR,
@@ -123,8 +125,12 @@ export class JsonRpcConnection {
123
125
 
124
126
  private async handleRequest(msg: JsonRpcRequest): Promise<void> {
125
127
  try {
128
+ // The boundary and the only cast: JSON-RPC `method` is untrusted input.
129
+ // Anything outside the union still reaches the bridge and is answered by
130
+ // its `default:` branch with -32601 — the type stops at this line, and no
131
+ // runtime allow-list is duplicated here.
126
132
  const result = await this.bridge.handleRequest(
127
- msg.method,
133
+ msg.method as RequestMethod,
128
134
  msg.params,
129
135
  msg.sessionId,
130
136
  );
@@ -141,7 +147,10 @@ export class JsonRpcConnection {
141
147
 
142
148
  private handleNotification(msg: JsonRpcNotification): void {
143
149
  try {
144
- this.bridge.handleNotification(msg.method, msg.params);
150
+ this.bridge.handleNotification(
151
+ msg.method as ClientNotificationMethod,
152
+ msg.params,
153
+ );
145
154
  } catch (err) {
146
155
  // Notifications don't get responses, but we log to stderr
147
156
  process.stderr.write(
@@ -44,121 +44,20 @@ export const METHOD_NOT_FOUND = -32601;
44
44
  export const INVALID_PARAMS = -32602;
45
45
  export const INTERNAL_ERROR = -32603;
46
46
 
47
- // ── Client → Server request methods ─────────────────────────────
48
-
49
- export type RequestMethod =
50
- | "initialize"
51
- | "destroy"
52
- | "restoreSession"
53
- | "listSessions"
54
- | "listAllSessions"
55
- | "renameSession"
56
- | "getSessionInfo"
57
- | "sendMessage"
58
- | "bang"
59
- | "askBtw"
60
- | "abortMessage"
61
- | "clearMessages"
62
- | "rewindToMessage"
63
- | "listRewindCheckpoints"
64
- | "deleteQueuedMessage"
65
- | "updateQueuedMessage"
66
- | "deleteQueuedMessageById"
67
- | "getMessages"
68
- | "getFullMessageThread"
69
- | "setPermissionMode"
70
- | "getPermissionMode"
71
- | "getPlanFile"
72
- | "getMcpServers"
73
- | "connectMcpServer"
74
- | "disconnectMcpServer"
75
- | "removeMcpServer"
76
- | "getMcpConfigPaths"
77
- | "getSlashCommands"
78
- | "getSubagentConfigurations"
79
- | "getSkillMetadata"
80
- | "deleteSkill"
81
- | "deleteSubagent"
82
- | "getHooksByScope"
83
- | "deleteHook"
84
- | "searchFiles"
85
- | "writeArtifactFile"
86
- | "getPromptHistory"
87
- | "searchPromptHistory"
88
- | "updateConfig"
89
- | "getConfiguredModels"
90
- | "setModel"
91
- // User preferences (global — read/write user-level ~/.wave/settings.json,
92
- // 设置页保存路径,见 docs/specs/core/agent-config.md「设置实时重载」)
93
- | "getUserSettings"
94
- | "updateUserSettings"
95
- // Permissions (daemon attach: re-surface pending approvals after reconnect)
96
- | "listPendingPermissions"
97
- // Daemon (global — list in-memory session registry, no session required)
98
- | "listDaemonSessions"
99
- // Auth
100
- | "getAuthStatus"
101
- | "getAccountInfo"
102
- | "login"
103
- | "logout"
104
- // Memory files (user-level ~/.wave/AGENTS.md and project-level <workdir>/AGENTS.md)
105
- | "getAgentsContent"
106
- | "setAgentsContent"
107
- // Plugins
108
- | "listPlugins"
109
- | "installPlugin"
110
- | "uninstallPlugin"
111
- | "enablePlugin"
112
- | "disablePlugin"
113
- | "updatePlugin"
114
- | "setPluginScope"
115
- | "reloadPlugins"
116
- | "listMarketplaces"
117
- | "addMarketplace"
118
- | "removeMarketplace"
119
- | "updateMarketplace"
120
- | "compact"
121
- | "getBackgroundTaskOutput"
122
- | "stopBackgroundTask"
123
- | "getWorkflowRuns"
124
- | "stopWorkflowRun"
125
- // Git / worktree (global — no session required)
126
- | "listGitBranches"
127
- | "createWorktree"
128
- | "getWorktreeChanges"
129
- | "removeWorktree";
130
-
131
- // ── Client → Server notification methods ────────────────────────
132
-
133
- export type ClientNotificationMethod = "permissionResponse";
134
-
135
- // ── Server → Client notification methods ────────────────────────
136
-
137
- export type ServerNotificationMethod =
138
- | "userMessageAdded"
139
- | "assistantMessageAdded"
140
- | "assistantContentUpdated"
141
- | "assistantReasoningUpdated"
142
- | "toolBlockUpdated"
143
- | "errorBlockAdded"
144
- | "loadingChange"
145
- | "commandRunningChange"
146
- | "queuedMessagesChange"
147
- | "tasksChange"
148
- | "sessionIdChange"
149
- | "permissionModeChange"
150
- | "mcpServersChange"
151
- | "workdirChange"
152
- | "notificationMessageAdded"
153
- | "permissionRequest"
154
- | "authUrl"
155
- | "compactBlockAdded"
156
- | "compactionStateChange"
157
- | "compactionContentUpdate"
158
- | "planFileUpdated"
159
- | "backgroundTasksChange"
160
- | "btwContent"
161
- | "contextUsage";
47
+ // ── Method / notification lists ────────────────────────────────
48
+ // Declared once, in the SDK (`src/types/stdioProtocol.ts`), because the RPC
49
+ // client lives there too and both sides of the wire must read the same union.
50
+ // Re-exported here so this package has one module to import from.
51
+ //
52
+ // The compiler — not a source-scanning test — is what keeps them honest: the
53
+ // bridge's `default:` branch is `never`-checked, and `emit` / `RpcClient` are
54
+ // typed with these unions, so a method added on one side without the other does
55
+ // not build.
56
+ export type {
57
+ ClientNotificationMethod,
58
+ RequestMethod,
59
+ ServerNotificationMethod,
60
+ } from "wave-agent-sdk";
162
61
 
163
62
  // ── Helper: is this a request (has id)? ─────────────────────────
164
63
 
@@ -9,23 +9,47 @@
9
9
  import type { Readable, Writable } from "stream";
10
10
  import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
11
11
  import { JsonRpcConnection } from "./jsonRpcConnection.js";
12
+ import type { RemoteControlSink } from "../remote/remoteControlSink.js";
12
13
 
13
14
  export interface StdioServerOptions {
14
15
  input?: Readable;
15
16
  output?: Writable;
16
17
  bridgeOptions?: AgentBridgeOptions;
18
+ /**
19
+ * Same seam as `DaemonServerOptions.createRemoteSink`, for the other server
20
+ * that owns sessions: the `--stdio` child a GUI host spawns. Unset means
21
+ * remote control is off for this child — the `remoteControl*` RPCs then
22
+ * report exactly that through the bridge's own error.
23
+ */
24
+ createRemoteSink?: (bridge: AgentBridge) => RemoteControlSink;
17
25
  }
18
26
 
19
27
  export class StdioServer {
20
28
  private bridge: AgentBridge;
21
29
  private conn: JsonRpcConnection;
30
+ private remoteSink: RemoteControlSink | undefined;
22
31
 
23
32
  constructor(options: StdioServerOptions = {}) {
33
+ let remoteSink: RemoteControlSink | undefined;
24
34
  this.bridge = new AgentBridge({
25
35
  ...options.bridgeOptions,
26
- emit: (method, params, sessionId) =>
27
- this.sendNotification(method, params, sessionId),
36
+ // The sink is handed the bridge it must route phone requests back
37
+ // through, so it can only be built after it; reading it lazily keeps the
38
+ // two from having to be constructed in a circle.
39
+ remoteControl: () => remoteSink,
40
+ emit: (method, params, sessionId) => {
41
+ this.sendNotification(method, params, sessionId);
42
+ // The phone must never be able to take the host's own client down with
43
+ // it, so a remote-path failure is contained here rather than thrown.
44
+ try {
45
+ this.remoteSink?.sendNotification(method, params, sessionId);
46
+ } catch {
47
+ // Dropped frame; the phone re-syncs on reconnect.
48
+ }
49
+ },
28
50
  });
51
+ remoteSink = options.createRemoteSink?.(this.bridge);
52
+ this.remoteSink = remoteSink;
29
53
  this.conn = new JsonRpcConnection(
30
54
  options.input ?? process.stdin,
31
55
  options.output ?? process.stdout,
@@ -43,6 +67,8 @@ export class StdioServer {
43
67
  }
44
68
 
45
69
  stop(): void {
70
+ this.remoteSink?.stop();
71
+ this.remoteSink = undefined;
46
72
  this.conn.stop();
47
73
  }
48
74
 
@@ -52,9 +78,14 @@ export class StdioServer {
52
78
  * auto-memory first; a timeout bounds it so a stuck agent can't orphan the
53
79
  * exit. This is the real-world "parent died" path: without it the process
54
80
  * lingers forever whenever an agent keeps event-loop handles alive.
81
+ *
82
+ * The relay link is dropped explicitly before the exit: leaving the socket to
83
+ * the OS would have the relay hold a subscription for a process that is gone.
55
84
  */
56
85
  private async onConnectionClosed(): Promise<void> {
57
86
  const SHUTDOWN_TIMEOUT_MS = 5_000;
87
+ this.remoteSink?.stop();
88
+ this.remoteSink = undefined;
58
89
  await Promise.race([
59
90
  this.bridge.destroyAll(),
60
91
  new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
package/src/stdio-cli.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  * the client sends an "initialize" request.
7
7
  */
8
8
 
9
- import { ensureRuntimeDeps } from "wave-agent-sdk";
9
+ import { ensureRuntimeDeps, setTelemetryStdoutProtected } from "wave-agent-sdk";
10
10
  import { StdioServer } from "./stdio/stdioServer.js";
11
+ import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
11
12
  import { logger } from "./utils/logger.js";
12
13
 
13
14
  /**
@@ -55,6 +56,10 @@ export function guardStdoutForJsonRpc(): void {
55
56
 
56
57
  export async function startStdioCli(): Promise<void> {
57
58
  guardStdoutForJsonRpc();
59
+ // stdout is the JSON-RPC channel here: console telemetry exporters would
60
+ // print onto it (OTel's console exporters use console.dir, which
61
+ // guardStdoutForJsonRpc does not cover) — strip them at config resolution.
62
+ setTelemetryStdoutProtected(true);
58
63
  // Install the on-demand runtime dependencies (image codec + ripgrep) before
59
64
  // answering any RPC (see cli.tsx). Blocking here is what closes the race: the
60
65
  // host's `initialize` — and with it the webview's initial state — only lands
@@ -69,7 +74,11 @@ export async function startStdioCli(): Promise<void> {
69
74
  crashHandler("unhandledRejection", reason),
70
75
  );
71
76
 
72
- const server = new StdioServer();
77
+ const server = new StdioServer({
78
+ // This child owns the sessions its host shows, so it — not a daemon on the
79
+ // same machine — is what serves a paired phone.
80
+ createRemoteSink: createRemoteControlSinkFactory(process.cwd()),
81
+ });
73
82
  server.start();
74
83
 
75
84
  // Keep the process alive — readline on stdin handles the event loop.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Git credential helper installed for repositories cloned from a `wave://clone`
3
+ * deep link (spec: docs/specs/desktop/desktop-deep-link.md「克隆出的仓库长期
4
+ * 自动认证」).
5
+ *
6
+ * What lands in the repository config is *this command*, never the credential:
7
+ *
8
+ * [credential]
9
+ * helper =
10
+ * helper = !exec '/Users/me/.wave/git-credential-wave'
11
+ *
12
+ * The empty first entry resets the helper list inherited from system/global
13
+ * config. Without it a machine-wide store/cache helper (osxkeychain, manager,
14
+ * credential-store) also runs and pins the first credential it sees, so later
15
+ * logins and token refreshes would never be picked up.
16
+ *
17
+ * The helper is a plain `/bin/sh` script rather than a call back into the CLI:
18
+ * git invokes it before *every* authenticated network operation, and booting
19
+ * the Electron-bundled Node runtime for that would add seconds to each push. It
20
+ * only needs the token sitting in `~/.wave/auth.json`, which the CLI keeps
21
+ * current (including background refreshes).
22
+ */
23
+
24
+ import { chmodSync, mkdirSync, writeFileSync } from "fs";
25
+ import * as path from "path";
26
+ import { AuthService } from "wave-agent-sdk";
27
+
28
+ /** Extensionless on purpose: git execs it through the shebang, and MSYS sh
29
+ * resolves a bare path that starts with `#!`. */
30
+ export const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-wave";
31
+
32
+ export interface InstalledGitCredentialHelper {
33
+ /** Absolute path of the generated script. */
34
+ path: string;
35
+ /** Ready-to-use `credential.helper` value (written into repo config). */
36
+ command: string;
37
+ }
38
+
39
+ /** `<home>/.wave/git-credential-wave` — next to `auth.json`. */
40
+ export function gitCredentialHelperPath(): string {
41
+ return path.join(
42
+ path.dirname(AuthService.getInstance().getAuthPath()),
43
+ GIT_CREDENTIAL_HELPER_FILENAME,
44
+ );
45
+ }
46
+
47
+ /**
48
+ * (Re)write the helper script and return the config value that installs it.
49
+ * Idempotent — safe to call on every deep-link clone.
50
+ */
51
+ export function installGitCredentialHelper(): InstalledGitCredentialHelper {
52
+ const scriptPath = gitCredentialHelperPath();
53
+ mkdirSync(path.dirname(scriptPath), { recursive: true });
54
+ writeFileSync(
55
+ scriptPath,
56
+ renderGitCredentialHelper(AuthService.getInstance().getAuthPath()),
57
+ "utf-8",
58
+ );
59
+ chmodSync(scriptPath, 0o755);
60
+ return { path: scriptPath, command: `!exec ${shellQuote(scriptPath)}` };
61
+ }
62
+
63
+ /** The script body. Exported for tests. */
64
+ export function renderGitCredentialHelper(authFilePath: string): string {
65
+ return `#!/bin/sh
66
+ # Wave git credential helper — generated by CodeWave IDE.
67
+ # Rewritten on every deep-link clone; do not edit by hand.
68
+ #
69
+ # Prints the current Wave SSO token, read fresh from the login store on each
70
+ # request, so logging in (or the CLI refreshing the token) takes effect without
71
+ # touching any repository.
72
+
73
+ AUTH_FILE=${shellQuote(authFilePath)}
74
+
75
+ # Drain git's request: we only look at the capabilities it advertises below, and
76
+ # a helper that exits before reading stdin can get EPIPE'd.
77
+ input=$(cat 2>/dev/null) || input=
78
+
79
+ token=$(sed -n 's/.*"SSO_TOKEN"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p' "$AUTH_FILE" 2>/dev/null | head -n 1)
80
+
81
+ if [ -z "$token" ]; then
82
+ # Not logged in on this machine. Stop git right here: quit=true skips the
83
+ # remaining helpers *and* the interactive prompt (there is no terminal when
84
+ # the agent runs git), so the operation fails fast instead of hanging.
85
+ echo "quit=true"
86
+ exit 0
87
+ fi
88
+
89
+ case "$input" in
90
+ *capability\\[\\]=authtype*)
91
+ echo "capability[]=authtype"
92
+ echo "authtype=bearer"
93
+ printf 'credential=%s\\n' "$token"
94
+ ;;
95
+ *)
96
+ echo "username=oauth2"
97
+ printf 'password=%s\\n' "$token"
98
+ ;;
99
+ esac
100
+ `;
101
+ }
102
+
103
+ /** Single-quote a value for `sh`, escaping embedded single quotes. */
104
+ export function shellQuote(value: string): string {
105
+ return `'${value.replace(/'/g, `'\\''`)}'`;
106
+ }
@@ -11,7 +11,7 @@
11
11
  import * as fs from "fs";
12
12
  import * as path from "path";
13
13
  import { Chalk } from "chalk";
14
- import { getLastLines } from "wave-agent-sdk";
14
+ import { getLastLines, errorReplacer } from "wave-agent-sdk";
15
15
  import { LOG_FILE } from "./constants.js";
16
16
 
17
17
  const chalk = new Chalk({ level: 3 });
@@ -132,7 +132,10 @@ const formatArg = (arg: unknown): string => {
132
132
  result = arg.stack || arg.message || String(arg);
133
133
  } else if (typeof arg === "object") {
134
134
  try {
135
- result = JSON.stringify(arg);
135
+ // The replacer keeps Errors nested inside a payload ({ error: e }) from
136
+ // serializing to `{}` — the `instanceof` check above only covers args
137
+ // passed bare.
138
+ result = JSON.stringify(arg, errorReplacer);
136
139
  } catch {
137
140
  // If JSON.stringify fails, fallback to String()
138
141
  result = String(arg);
@@ -2,15 +2,13 @@ import type { Message } from "wave-agent-sdk";
2
2
 
3
3
  /**
4
4
  * 判断一条 user 消息能否作为 /rewind 检查点。
5
- * 后台任务通知(task_notification)、hook 注入的消息(source: "hook")
6
- * 都是系统生成、用户不可见的,不能作为回滚点。bash 模式命令消息
5
+ * 后台任务通知(task_notification)与 hook 注入的消息都是系统生成、
6
+ * 用户不可见的(meta),不能作为回滚点。bash 模式命令消息
7
7
  * (`!ls`)是用户真正输入,与 fork skill 命令消息一致,可作为回滚点。
8
8
  * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
9
9
  */
10
10
  export function isUserCheckpointMessage(m: Message): boolean {
11
11
  if (m.role !== "user" || m.isMeta) return false;
12
12
  if (m.blocks.some((b) => b.type === "task_notification")) return false;
13
- if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
14
- return false;
15
13
  return true;
16
14
  }