@namzu/sdk 52.3.0 → 52.5.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 (176) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/authorization/gate.d.ts +11 -0
  3. package/dist/authorization/gate.d.ts.map +1 -1
  4. package/dist/authorization/gate.js +35 -0
  5. package/dist/authorization/gate.js.map +1 -1
  6. package/dist/authorization/removal.js +11 -6
  7. package/dist/authorization/removal.js.map +1 -1
  8. package/dist/authorization/rules.d.ts.map +1 -1
  9. package/dist/authorization/rules.js +30 -2
  10. package/dist/authorization/rules.js.map +1 -1
  11. package/dist/authorization/shell-paths.d.ts +20 -0
  12. package/dist/authorization/shell-paths.d.ts.map +1 -1
  13. package/dist/authorization/shell-paths.js +254 -52
  14. package/dist/authorization/shell-paths.js.map +1 -1
  15. package/dist/bridge/acp/server.d.ts +8 -0
  16. package/dist/bridge/acp/server.d.ts.map +1 -1
  17. package/dist/bridge/acp/server.js +7 -0
  18. package/dist/bridge/acp/server.js.map +1 -1
  19. package/dist/connector/web/guarded-fetch.d.ts.map +1 -1
  20. package/dist/connector/web/guarded-fetch.js +44 -0
  21. package/dist/connector/web/guarded-fetch.js.map +1 -1
  22. package/dist/manager/resident/outbox.d.ts +4 -4
  23. package/dist/process/kill-tree.d.ts +3 -2
  24. package/dist/process/kill-tree.d.ts.map +1 -1
  25. package/dist/process/kill-tree.js.map +1 -1
  26. package/dist/public-runtime.d.ts +3 -2
  27. package/dist/public-runtime.d.ts.map +1 -1
  28. package/dist/public-runtime.js +4 -1
  29. package/dist/public-runtime.js.map +1 -1
  30. package/dist/public-tools.d.ts +2 -1
  31. package/dist/public-tools.d.ts.map +1 -1
  32. package/dist/public-tools.js +1 -1
  33. package/dist/public-tools.js.map +1 -1
  34. package/dist/public-types.d.ts +1 -0
  35. package/dist/public-types.d.ts.map +1 -1
  36. package/dist/runtime/jobs/registry.d.ts +80 -2
  37. package/dist/runtime/jobs/registry.d.ts.map +1 -1
  38. package/dist/runtime/jobs/registry.js +70 -5
  39. package/dist/runtime/jobs/registry.js.map +1 -1
  40. package/dist/runtime/jobs/websocket-source.d.ts +120 -0
  41. package/dist/runtime/jobs/websocket-source.d.ts.map +1 -0
  42. package/dist/runtime/jobs/websocket-source.js +379 -0
  43. package/dist/runtime/jobs/websocket-source.js.map +1 -0
  44. package/dist/runtime/query/auto-review/classifier.d.ts +130 -0
  45. package/dist/runtime/query/auto-review/classifier.d.ts.map +1 -0
  46. package/dist/runtime/query/auto-review/classifier.js +244 -0
  47. package/dist/runtime/query/auto-review/classifier.js.map +1 -0
  48. package/dist/runtime/query/auto-review/index.d.ts +7 -0
  49. package/dist/runtime/query/auto-review/index.d.ts.map +1 -0
  50. package/dist/runtime/query/auto-review/index.js +4 -0
  51. package/dist/runtime/query/auto-review/index.js.map +1 -0
  52. package/dist/runtime/query/auto-review/prompt.d.ts +20 -0
  53. package/dist/runtime/query/auto-review/prompt.d.ts.map +1 -0
  54. package/dist/runtime/query/auto-review/prompt.js +89 -0
  55. package/dist/runtime/query/auto-review/prompt.js.map +1 -0
  56. package/dist/runtime/query/auto-review/rules.d.ts +49 -0
  57. package/dist/runtime/query/auto-review/rules.d.ts.map +1 -0
  58. package/dist/runtime/query/auto-review/rules.js +81 -0
  59. package/dist/runtime/query/auto-review/rules.js.map +1 -0
  60. package/dist/runtime/query/auto-review/transcript.d.ts +59 -0
  61. package/dist/runtime/query/auto-review/transcript.d.ts.map +1 -0
  62. package/dist/runtime/query/auto-review/transcript.js +116 -0
  63. package/dist/runtime/query/auto-review/transcript.js.map +1 -0
  64. package/dist/runtime/query/auto-review/verdict.d.ts +21 -0
  65. package/dist/runtime/query/auto-review/verdict.d.ts.map +1 -0
  66. package/dist/runtime/query/auto-review/verdict.js +45 -0
  67. package/dist/runtime/query/auto-review/verdict.js.map +1 -0
  68. package/dist/runtime/query/executor.d.ts +7 -0
  69. package/dist/runtime/query/executor.d.ts.map +1 -1
  70. package/dist/runtime/query/executor.js +26 -2
  71. package/dist/runtime/query/executor.js.map +1 -1
  72. package/dist/runtime/query/index.js +2 -2
  73. package/dist/runtime/query/index.js.map +1 -1
  74. package/dist/runtime/query/iteration/phases/context.d.ts +0 -9
  75. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  76. package/dist/runtime/query/iteration/phases/context.js +11 -2
  77. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  78. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  79. package/dist/runtime/query/iteration/phases/tool-review.js +39 -0
  80. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  81. package/dist/runtime/query/path-escalation.d.ts.map +1 -1
  82. package/dist/runtime/query/path-escalation.js +43 -10
  83. package/dist/runtime/query/path-escalation.js.map +1 -1
  84. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  85. package/dist/runtime/query/resume-pending.js +14 -0
  86. package/dist/runtime/query/resume-pending.js.map +1 -1
  87. package/dist/runtime/query/review-policy.d.ts +88 -1
  88. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  89. package/dist/runtime/query/review-policy.js +504 -11
  90. package/dist/runtime/query/review-policy.js.map +1 -1
  91. package/dist/runtime/query/steering.d.ts +2 -2
  92. package/dist/runtime/query/steering.d.ts.map +1 -1
  93. package/dist/runtime/query/steering.js +16 -3
  94. package/dist/runtime/query/steering.js.map +1 -1
  95. package/dist/tools/builtins/bash.d.ts.map +1 -1
  96. package/dist/tools/builtins/bash.js +2 -0
  97. package/dist/tools/builtins/bash.js.map +1 -1
  98. package/dist/tools/builtins/classifier-input.d.ts +9 -0
  99. package/dist/tools/builtins/classifier-input.d.ts.map +1 -0
  100. package/dist/tools/builtins/classifier-input.js +20 -0
  101. package/dist/tools/builtins/classifier-input.js.map +1 -0
  102. package/dist/tools/builtins/code-analysis.d.ts +2 -0
  103. package/dist/tools/builtins/code-analysis.d.ts.map +1 -1
  104. package/dist/tools/builtins/code-analysis.js +196 -119
  105. package/dist/tools/builtins/code-analysis.js.map +1 -1
  106. package/dist/tools/builtins/computer-use.d.ts +10 -10
  107. package/dist/tools/builtins/edit.d.ts.map +1 -1
  108. package/dist/tools/builtins/edit.js +12 -0
  109. package/dist/tools/builtins/edit.js.map +1 -1
  110. package/dist/tools/builtins/monitor.d.ts +38 -4
  111. package/dist/tools/builtins/monitor.d.ts.map +1 -1
  112. package/dist/tools/builtins/monitor.js +239 -97
  113. package/dist/tools/builtins/monitor.js.map +1 -1
  114. package/dist/tools/builtins/web.d.ts.map +1 -1
  115. package/dist/tools/builtins/web.js +3 -0
  116. package/dist/tools/builtins/web.js.map +1 -1
  117. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  118. package/dist/tools/builtins/write-file.js +6 -0
  119. package/dist/tools/builtins/write-file.js.map +1 -1
  120. package/dist/tools/defineTool.d.ts +2 -0
  121. package/dist/tools/defineTool.d.ts.map +1 -1
  122. package/dist/tools/defineTool.js +1 -0
  123. package/dist/tools/defineTool.js.map +1 -1
  124. package/dist/types/acp/index.d.ts +7 -0
  125. package/dist/types/acp/index.d.ts.map +1 -1
  126. package/dist/types/code-analysis/index.d.ts +54 -3
  127. package/dist/types/code-analysis/index.d.ts.map +1 -1
  128. package/dist/types/hitl/index.d.ts +45 -1
  129. package/dist/types/hitl/index.d.ts.map +1 -1
  130. package/dist/types/hitl/index.js.map +1 -1
  131. package/dist/types/sandbox/index.d.ts +10 -0
  132. package/dist/types/sandbox/index.d.ts.map +1 -1
  133. package/dist/types/sandbox/index.js.map +1 -1
  134. package/dist/types/tool/index.d.ts +51 -0
  135. package/dist/types/tool/index.d.ts.map +1 -1
  136. package/dist/types/tool/index.js.map +1 -1
  137. package/package.json +4 -2
  138. package/src/authorization/gate.ts +35 -0
  139. package/src/authorization/removal.ts +12 -6
  140. package/src/authorization/rules.ts +27 -2
  141. package/src/authorization/shell-paths.ts +264 -51
  142. package/src/bridge/acp/server.ts +20 -0
  143. package/src/connector/web/guarded-fetch.ts +41 -0
  144. package/src/process/kill-tree.ts +4 -2
  145. package/src/public-runtime.ts +19 -1
  146. package/src/public-tools.ts +3 -0
  147. package/src/public-types.ts +11 -0
  148. package/src/runtime/jobs/registry.ts +162 -13
  149. package/src/runtime/jobs/websocket-source.ts +438 -0
  150. package/src/runtime/query/auto-review/classifier.ts +404 -0
  151. package/src/runtime/query/auto-review/index.ts +23 -0
  152. package/src/runtime/query/auto-review/prompt.ts +97 -0
  153. package/src/runtime/query/auto-review/rules.ts +106 -0
  154. package/src/runtime/query/auto-review/transcript.ts +159 -0
  155. package/src/runtime/query/auto-review/verdict.ts +50 -0
  156. package/src/runtime/query/executor.ts +29 -2
  157. package/src/runtime/query/index.ts +2 -2
  158. package/src/runtime/query/iteration/phases/context.ts +17 -3
  159. package/src/runtime/query/iteration/phases/tool-review.ts +41 -0
  160. package/src/runtime/query/path-escalation.ts +44 -9
  161. package/src/runtime/query/resume-pending.ts +14 -0
  162. package/src/runtime/query/review-policy.ts +591 -14
  163. package/src/runtime/query/steering.ts +32 -6
  164. package/src/tools/builtins/bash.ts +3 -0
  165. package/src/tools/builtins/classifier-input.ts +19 -0
  166. package/src/tools/builtins/code-analysis.ts +211 -119
  167. package/src/tools/builtins/edit.ts +13 -0
  168. package/src/tools/builtins/monitor.ts +297 -123
  169. package/src/tools/builtins/web.ts +3 -0
  170. package/src/tools/builtins/write-file.ts +6 -0
  171. package/src/tools/defineTool.ts +3 -0
  172. package/src/types/acp/index.ts +7 -0
  173. package/src/types/code-analysis/index.ts +51 -3
  174. package/src/types/hitl/index.ts +49 -1
  175. package/src/types/sandbox/index.ts +10 -0
  176. package/src/types/tool/index.ts +44 -0
@@ -6,6 +6,17 @@ import {
6
6
  MONITOR_TIMEOUT_MIN_MS,
7
7
  formatMonitorDuration,
8
8
  } from '../../runtime/jobs/monitor-stream.js'
9
+ import {
10
+ WEBSOCKET_PROTOCOLS_MAX,
11
+ WebSocketRefusedError,
12
+ type WebSocketVetOptions,
13
+ vetWebSocketTarget,
14
+ webSocketHostname,
15
+ webSocketProtocolsProblem,
16
+ webSocketSandboxRefusal,
17
+ webSocketUrlProblem,
18
+ } from '../../runtime/jobs/websocket-source.js'
19
+ import type { MonitorWebSocketTarget } from '../../types/tool/index.js'
9
20
  import {
10
21
  bashToolDialect,
11
22
  hostCommandShell,
@@ -38,42 +49,82 @@ import {
38
49
  * read the command the way they read `bash`'s, and it is never read-only. It
39
50
  * has no way out of the sandbox: a watch that outlives one approved call must
40
51
  * not run outside the boundary that call was approved under.
52
+ *
53
+ * A source can also be a WebSocket (`ws`), for services that push their events
54
+ * and offer no command that prints them. That one is a different thing to
55
+ * approve: it is not a shell line, it opens a connection FROM THE HOST to an
56
+ * address the model chose, and it does so outside any sandbox. So it is read
57
+ * by no command rule, it is always reviewed, a sandbox that blocks the network
58
+ * refuses it, and the address is checked and pinned (see
59
+ * `runtime/jobs/websocket-source.ts`). Untrusted frames reach the model the way
60
+ * a command's lines do: through the monitor event path, in its untrusted frame.
41
61
  */
42
62
 
43
- const inputSchema = z.object({
44
- description: z
45
- .string()
46
- .min(1)
47
- .max(200)
48
- .describe(
49
- 'What you are watching, in a few specific words. It is shown with every event ("Monitor event: <description>"), so make it identify this watch among several: "errors in deploy log", not "monitor".',
50
- ),
51
- command: z
63
+ const webSocketInput = z.object({
64
+ url: z
52
65
  .string()
53
- .min(1)
54
- // Same rewrite as `bash`, for the same reason: the rules read the line a
55
- // shell will run.
56
- .transform((command) =>
57
- isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
58
- )
59
- .describe(
60
- 'The shell command to watch. Each line it prints to stdout becomes an event. It runs until it ends or the timeout; it must not need stdin.',
61
- ),
62
- timeout_ms: z
63
- .preprocess(
64
- (v) => (typeof v === 'string' ? Number(v) : v),
65
- z
66
- .number()
67
- .int()
68
- .min(MONITOR_TIMEOUT_MIN_MS)
69
- .max(MONITOR_TIMEOUT_MAX_MS)
70
- .default(MONITOR_TIMEOUT_DEFAULT_MS),
71
- )
66
+ .superRefine((value, ctx) => {
67
+ const problem = webSocketUrlProblem(value)
68
+ if (problem !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem })
69
+ })
72
70
  .describe(
73
- `How long to watch, in milliseconds. Default ${MONITOR_TIMEOUT_DEFAULT_MS}. The host caps it (30 minutes by default; shorter in a single-shot run), and a larger value is lowered to that cap without an error; the result states the time actually granted.`,
71
+ 'The ws:// or wss:// URL to connect to. A query string is kept; credentials in it are refused.',
74
72
  ),
73
+ protocols: z
74
+ .array(z.string())
75
+ .max(WEBSOCKET_PROTOCOLS_MAX)
76
+ .superRefine((value, ctx) => {
77
+ const problem = webSocketProtocolsProblem(value)
78
+ if (problem !== undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem })
79
+ })
80
+ .optional()
81
+ .describe('Subprotocol names to offer, when the service asks for one.'),
75
82
  })
76
83
 
84
+ const inputSchema = z
85
+ .object({
86
+ description: z
87
+ .string()
88
+ .min(1)
89
+ .max(200)
90
+ .describe(
91
+ 'What you are watching, in a few specific words. It is shown with every event ("Monitor event: <description>"), so make it identify this watch among several: "errors in deploy log", not "monitor".',
92
+ ),
93
+ command: z
94
+ .string()
95
+ .min(1)
96
+ // Same rewrite as `bash`, for the same reason: the rules read the line a
97
+ // shell will run.
98
+ .transform((command) =>
99
+ isWindowsBash(hostCommandShell()) ? rewriteWindowsNullRedirect(command) : command,
100
+ )
101
+ .optional()
102
+ .describe(
103
+ 'The shell command to watch. Each line it prints to stdout becomes an event. It runs until it ends or the timeout; it must not need stdin. Give this or ws, not both.',
104
+ ),
105
+ ws: webSocketInput
106
+ .optional()
107
+ .describe(
108
+ 'Watch a WebSocket instead of a command: each line of each text frame the server sends becomes an event. Give this or command, not both.',
109
+ ),
110
+ timeout_ms: z
111
+ .preprocess(
112
+ (v) => (typeof v === 'string' ? Number(v) : v),
113
+ z
114
+ .number()
115
+ .int()
116
+ .min(MONITOR_TIMEOUT_MIN_MS)
117
+ .max(MONITOR_TIMEOUT_MAX_MS)
118
+ .default(MONITOR_TIMEOUT_DEFAULT_MS),
119
+ )
120
+ .describe(
121
+ `How long to watch, in milliseconds. Default ${MONITOR_TIMEOUT_DEFAULT_MS}. The host caps it (30 minutes by default; shorter in a single-shot run), and a larger value is lowered to that cap without an error; the result states the time actually granted.`,
122
+ ),
123
+ })
124
+ .refine((input) => (input.command === undefined) !== (input.ws === undefined), {
125
+ message: 'Give exactly one of command and ws.',
126
+ })
127
+
77
128
  type MonitorInput = z.infer<typeof inputSchema>
78
129
 
79
130
  export const MONITOR_TOOL_NAME = 'monitor'
@@ -88,109 +139,231 @@ export const MONITOR_CANNOT_DETACH =
88
139
  'monitor is unavailable: this sandbox cannot start a detached process, and the watch will not be run on the host to get around it. Run the command in the foreground with bash, with a timeout.'
89
140
 
90
141
  const MONITOR_DESCRIPTION =
91
- 'Starts a command and tells you about each line it prints to stdout, as it prints it, while you keep working. Lines that arrive within 200 ms come together as one event. Use it to watch a log, a test or deploy run, a status endpoint — anything where you must react to something happening INSIDE a long-running command. For ONE notification when something finishes, do not use this: run bash with run_in_background and wait_for_job. Every stage of a pipe must flush per line or events arrive late or never (grep --line-buffered, awk with fflush(), sed -u; `head` cannot). Make the filter match failure as well as success (crash, Traceback, Error, FAILED, Killed…): silence from a monitor is not success. Poll a remote API no faster than every 30 seconds. stderr is not an event; read it with job. A monitor that prints too much is stopped, so filter at the source. It expires after the timeout (default 5 minutes, at most 30) and you get one notice; start it again if you still need it. Stop one early with job {"action":"kill","id":"<id>"}.'
92
-
93
- export const MonitorTool = defineTool({
94
- name: MONITOR_TOOL_NAME,
95
- description: MONITOR_DESCRIPTION,
96
- inputSchema,
97
- category: 'shell',
98
- // Bash's, because it IS a shell command: the gate reads the line, splits
99
- // chains, and applies the operator's rules to each part.
100
- commandArgument: 'command',
101
- commandDialect: bashToolDialect,
102
- permissions: ['shell_execute'],
103
- readOnly: false,
104
- destructive: (input: MonitorInput) => isDangerousCommand(input.command),
105
- // Starting a monitor registers a job against the owner's cap and its id
106
- // is then named in later calls; two at once could race the cap, as they
107
- // can for a background `bash`.
108
- concurrencySafe: false,
109
-
110
- presentCall(input) {
111
- if (typeof input.command !== 'string') return undefined
112
- return {
113
- kind: 'generic',
114
- label: `Monitor · ${typeof input.description === 'string' ? input.description : input.command}`,
115
- facts: { kind: 'command', command: input.command, background: true },
116
- }
117
- },
118
-
119
- async execute(input, context) {
120
- if (isDangerousCommand(input.command)) {
121
- return {
122
- success: false,
123
- output: '',
124
- error: `Dangerous command blocked: "${input.command}"`,
125
- facts: { kind: 'failure', reason: 'blocked', subject: MONITOR_TOOL_NAME },
142
+ 'Starts a command and tells you about each line it prints to stdout, as it prints it, while you keep working. Lines that arrive within 200 ms come together as one event. Use it to watch a log, a test or deploy run, a status endpoint — anything where you must react to something happening INSIDE a long-running command. For ONE notification when something finishes, do not use this: run bash with run_in_background and wait_for_job. Every stage of a pipe must flush per line or events arrive late or never (grep --line-buffered, awk with fflush(), sed -u; `head` cannot). Make the filter match failure as well as success (crash, Traceback, Error, FAILED, Killed…): silence from a monitor is not success. Poll a remote API no faster than every 30 seconds. stderr is not an event; read it with job. A monitor that prints too much is stopped, so filter at the source. It expires after the timeout (default 5 minutes, at most 30) and you get one notice; start it again if you still need it. Stop one early with job {"action":"kill","id":"<id>"}. ' +
143
+ 'To watch a service that PUSHES events (a CI or log stream, a change feed) give ws instead of command: { "url": "wss://…", "protocols": [...] }. Each line of each text frame becomes an event, binary frames are only counted, and the watch ends when the server closes the connection. Use it for a push stream, not for request and response. It connects from this machine, so private and local addresses are refused, and you are asked before it connects.'
144
+
145
+ /** What a host can tune about the WebSocket source; for tests only, see {@link WebSocketVetOptions}. */
146
+ export interface MonitorToolOptions {
147
+ readonly webSocket?: WebSocketVetOptions
148
+ /**
149
+ * How to turn web access on, in the host's own words, appended to the
150
+ * refusal a WebSocket call gets while it is off. The SDK cannot name a
151
+ * switch it does not own (a CLI config key, a desktop setting), and a
152
+ * refusal that only says "off" leaves the operator guessing. One short
153
+ * sentence; it is shown to the model and the operator as written.
154
+ */
155
+ readonly webAccessHint?: string
156
+ }
157
+
158
+ /** The text that says a WebSocket watch was refused because the host has web access off. */
159
+ export const MONITOR_WEBSOCKET_WEB_OFF =
160
+ "WebSocket monitors follow the host's web access setting, and web access is switched off here, so no connection was opened. Watch the service with a command (for example curl or websocat through monitor) if the operator allows it."
161
+
162
+ const MONITOR_WEBSOCKET_NOT_SUPPORTED =
163
+ 'This host cannot start a WebSocket monitor, so no connection was opened. Watch the service with a command through monitor if the operator allows it.'
164
+
165
+ /**
166
+ * The tool, built once with defaults for a host and again by a test that needs
167
+ * to reach a server on this machine. The options belong to whoever builds the
168
+ * tool; nothing a model sends can set them.
169
+ */
170
+ export function createMonitorTool(options: MonitorToolOptions = {}) {
171
+ return defineTool({
172
+ name: MONITOR_TOOL_NAME,
173
+ description: MONITOR_DESCRIPTION,
174
+ inputSchema,
175
+ category: 'shell',
176
+ // Bash's, because it IS a shell command: the gate reads the line, splits
177
+ // chains, and applies the operator's rules to each part. A WebSocket call
178
+ // has no `command`, so a rule written for commands has nothing to match
179
+ // and cannot allow it; `requiresApproval` below is what asks.
180
+ commandArgument: 'command',
181
+ classifierInput: (input: MonitorInput) =>
182
+ input.command !== undefined
183
+ ? `[long-running watch] ${input.command}`
184
+ : `[long-running WebSocket watch] ${JSON.stringify(input.ws)}`,
185
+ commandDialect: bashToolDialect,
186
+ // `network_access` too: a WebSocket call reaches the network. The gate
187
+ // reads a tool's `category`, not this list, to route a call, so declaring
188
+ // it changes no decision for a command; it says what the tool may do.
189
+ permissions: ['shell_execute', 'network_access'],
190
+ readOnly: false,
191
+ destructive: (input: MonitorInput) =>
192
+ input.command !== undefined && isDangerousCommand(input.command),
193
+ // A WebSocket call is the one input-aware seam the gate offers that no
194
+ // allow rule or mode outranks: it connects from the host, outside any
195
+ // sandbox, to an address the model chose. `category: 'shell'` is what the
196
+ // sandboxed-shell preset auto-approves, and moving the whole tool to
197
+ // `network` would start asking about commands that were never a question;
198
+ // this asks only about the connection. It is stronger than "review where
199
+ // the mode reviews non-read-only calls": `auto` mode and an unattended
200
+ // turn are asked too (or refuse when nobody can be asked), which is the
201
+ // point for a connection that leaves the boundary those modes rely on.
202
+ requiresApproval: (input: MonitorInput) => input.ws !== undefined,
203
+ // Starting a monitor registers a job against the owner's cap and its id
204
+ // is then named in later calls; two at once could race the cap, as they
205
+ // can for a background `bash`.
206
+ concurrencySafe: false,
207
+
208
+ presentCall(input) {
209
+ const label = typeof input.description === 'string' ? input.description : undefined
210
+ if (input.ws !== undefined && typeof input.ws === 'object') {
211
+ const ws = input.ws as { url?: unknown; protocols?: unknown }
212
+ if (typeof ws.url !== 'string') return undefined
213
+ const protocols =
214
+ Array.isArray(ws.protocols) && ws.protocols.length > 0
215
+ ? ` (subprotocols: ${ws.protocols.join(', ')})`
216
+ : ''
217
+ // What will happen, said plainly, because this is what a person
218
+ // approves: no command line stands in for it.
219
+ return {
220
+ kind: 'generic',
221
+ label: `Monitor${label ? ` · ${label}` : ''} · WebSocket ${ws.url}${protocols}`,
222
+ }
126
223
  }
127
- }
128
- if (backgroundTasksDisabled()) {
224
+ if (typeof input.command !== 'string') return undefined
129
225
  return {
130
- success: false,
131
- output: '',
132
- error: `Background jobs are switched off here (${DISABLE_BACKGROUND_TASKS_ENV}), and a monitor is one. Check in the foreground with bash instead.`,
226
+ kind: 'generic',
227
+ label: `Monitor · ${label ?? input.command}`,
228
+ facts: { kind: 'command', command: input.command, background: true },
133
229
  }
134
- }
135
- // Same rule as background bash: the executor withholds the registry from
136
- // a sandbox that cannot start a detached process, and this check is for
137
- // the direct caller who hands both.
138
- if (context.sandbox && context.sandbox.spawnDetached === undefined) {
139
- return { success: false, output: '', error: MONITOR_CANNOT_DETACH }
140
- }
141
- const jobs = context.backgroundJobs
142
- if (!jobs) {
143
- return {
144
- success: false,
145
- output: '',
146
- error:
147
- 'monitor was requested, but this host provides no background job registry. Run the command in the foreground with bash, with a timeout.',
230
+ },
231
+
232
+ async execute(input, context) {
233
+ if (input.command !== undefined && isDangerousCommand(input.command)) {
234
+ return {
235
+ success: false,
236
+ output: '',
237
+ error: `Dangerous command blocked: "${input.command}"`,
238
+ facts: { kind: 'failure', reason: 'blocked', subject: MONITOR_TOOL_NAME },
239
+ }
148
240
  }
149
- }
150
- if (!jobs.startMonitor) {
151
- return {
152
- success: false,
153
- output: '',
154
- error:
155
- 'This host cannot deliver monitor events, so no watch was started. Use bash with run_in_background and wait_for_job to be told when a command finishes.',
241
+ if (backgroundTasksDisabled()) {
242
+ return {
243
+ success: false,
244
+ output: '',
245
+ error: `Background jobs are switched off here (${DISABLE_BACKGROUND_TASKS_ENV}), and a monitor is one. Check in the foreground with bash instead.`,
246
+ }
247
+ }
248
+ // What is checked about a connection is checked before anything that
249
+ // speaks of commands, so a refusal names its own cause.
250
+ let webSocket: MonitorWebSocketTarget | undefined
251
+ if (input.ws !== undefined) {
252
+ // The operator's switch for reaching the web at all. A host that has
253
+ // not given this turn a web capability has not decided to let the
254
+ // model open connections, and a WebSocket is one.
255
+ if (context.web?.fetch === undefined) {
256
+ return {
257
+ success: false,
258
+ output: '',
259
+ error: options.webAccessHint
260
+ ? `${MONITOR_WEBSOCKET_WEB_OFF} ${options.webAccessHint}`
261
+ : MONITOR_WEBSOCKET_WEB_OFF,
262
+ }
263
+ }
264
+ const hostname = webSocketHostname(input.ws.url)
265
+ const sandboxRefusal = webSocketSandboxRefusal(context.sandbox, hostname)
266
+ if (sandboxRefusal !== undefined) {
267
+ return { success: false, output: '', error: sandboxRefusal }
268
+ }
269
+ try {
270
+ const addresses = await vetWebSocketTarget(input.ws.url, options.webSocket)
271
+ webSocket = {
272
+ url: input.ws.url,
273
+ ...(input.ws.protocols ? { protocols: input.ws.protocols } : {}),
274
+ addresses,
275
+ }
276
+ } catch (err) {
277
+ if (err instanceof WebSocketRefusedError) {
278
+ return { success: false, output: '', error: err.message }
279
+ }
280
+ throw err
281
+ }
282
+ } else if (context.sandbox && context.sandbox.spawnDetached === undefined) {
283
+ // Same rule as background bash: the executor withholds the registry
284
+ // from a sandbox that cannot start a detached process, and this check
285
+ // is for the direct caller who hands both.
286
+ return { success: false, output: '', error: MONITOR_CANNOT_DETACH }
156
287
  }
157
- }
158
-
159
- try {
160
- const { job, timeoutMs } = jobs.startMonitor({
161
- command: input.command,
162
- description: input.description,
163
- timeoutMs: input.timeout_ms,
164
- workingDirectory: context.workingDirectory,
165
- })
166
- // `startMonitor` returns before the process exists, and a spawn that
167
- // fails reports a tick later; wait for that one event so "started" is
168
- // never said of a watch that could not start.
169
- const startup = await jobs.awaitStarted?.(job.id)
170
- if (startup?.error !== undefined) {
288
+ const jobs = context.backgroundJobs
289
+ if (!jobs) {
171
290
  return {
172
291
  success: false,
173
292
  output: '',
174
- error: `${startup.error} (monitor ${job.id} did not run).`,
175
- data: { jobId: job.id, monitor: true, startFailed: true },
293
+ error:
294
+ 'monitor was requested, but this host provides no background job registry. Run the command in the foreground with bash, with a timeout.',
176
295
  }
177
296
  }
178
- return {
179
- success: true,
180
- output: `Monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the command ends first; you get one notice at expiry — start it again if you still need the watch). Each line the command prints to stdout reaches you as an event while you keep working; do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
181
- data: { jobId: job.id, monitor: true, timeoutMs },
182
- facts: { kind: 'command', command: input.command, background: true },
297
+ if (webSocket !== undefined && !jobs.startWebSocketMonitor) {
298
+ return {
299
+ success: false,
300
+ output: '',
301
+ error: MONITOR_WEBSOCKET_NOT_SUPPORTED,
302
+ }
183
303
  }
184
- } catch (err) {
185
- // The per-owner cap, most likely; naming the limit is actionable.
186
- return {
187
- success: false,
188
- output: '',
189
- error: err instanceof Error ? err.message : String(err),
304
+ if (webSocket === undefined && !jobs.startMonitor) {
305
+ return {
306
+ success: false,
307
+ output: '',
308
+ error:
309
+ 'This host cannot deliver monitor events, so no watch was started. Use bash with run_in_background and wait_for_job to be told when a command finishes.',
310
+ }
190
311
  }
191
- }
192
- },
193
- })
312
+
313
+ try {
314
+ const common = {
315
+ description: input.description,
316
+ timeoutMs: input.timeout_ms,
317
+ workingDirectory: context.workingDirectory,
318
+ }
319
+ const { job, timeoutMs } =
320
+ webSocket !== undefined
321
+ ? (jobs.startWebSocketMonitor as NonNullable<typeof jobs.startWebSocketMonitor>)({
322
+ ...common,
323
+ webSocket,
324
+ })
325
+ : (jobs.startMonitor as NonNullable<typeof jobs.startMonitor>)({
326
+ ...common,
327
+ command: input.command as string,
328
+ })
329
+ // `startMonitor` returns before the process exists, and a spawn that
330
+ // fails reports a tick later; wait for that one event so "started" is
331
+ // never said of a watch that could not start.
332
+ const startup = await jobs.awaitStarted?.(job.id)
333
+ if (startup?.error !== undefined) {
334
+ return {
335
+ success: false,
336
+ output: '',
337
+ error: `${startup.error} (monitor ${job.id} did not run).`,
338
+ data: { jobId: job.id, monitor: true, startFailed: true },
339
+ }
340
+ }
341
+ if (webSocket !== undefined) {
342
+ return {
343
+ success: true,
344
+ output: `WebSocket monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the server closes the connection first; you get one notice at expiry — start it again if you still need the watch). Each line of each text frame the server sends reaches you as an event while you keep working; binary frames are only counted. The connection opens in the background: if it fails you are told in an event. Do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
345
+ data: { jobId: job.id, monitor: true, timeoutMs, transport: 'websocket' },
346
+ }
347
+ }
348
+ return {
349
+ success: true,
350
+ output: `Monitor started (job ${job.id}, expires in ${formatMonitorDuration(timeoutMs)} unless the command ends first; you get one notice at expiry — start it again if you still need the watch). Each line the command prints to stdout reaches you as an event while you keep working; do not poll or sleep. Stop it with job {"action":"kill","id":"${job.id}"}. An event may arrive while you are waiting for the user — it is not their reply.`,
351
+ data: { jobId: job.id, monitor: true, timeoutMs },
352
+ facts: { kind: 'command', command: input.command as string, background: true },
353
+ }
354
+ } catch (err) {
355
+ // The per-owner cap, most likely; naming the limit is actionable.
356
+ return {
357
+ success: false,
358
+ output: '',
359
+ error: err instanceof Error ? err.message : String(err),
360
+ }
361
+ }
362
+ },
363
+ })
364
+ }
365
+
366
+ export const MonitorTool = createMonitorTool()
194
367
 
195
368
  /**
196
369
  * The tool, for a host that opts in.
@@ -199,8 +372,9 @@ export const MonitorTool = defineTool({
199
372
  * wakes on them, and offering a tool that starts a job nobody will hear from
200
373
  * is worse than not offering it. A function rather than a constant so a host
201
374
  * can decide per turn, and so it reads like `getCodeAnalysisTools`, beside
202
- * which a host mounts it deferred.
375
+ * which a host mounts it deferred. Called with no options it returns the
376
+ * shared {@link MonitorTool}.
203
377
  */
204
- export function getMonitorTools(): (typeof MonitorTool)[] {
205
- return [MonitorTool]
378
+ export function getMonitorTools(options?: MonitorToolOptions): (typeof MonitorTool)[] {
379
+ return [options === undefined ? MonitorTool : createMonitorTool(options)]
206
380
  }
@@ -11,6 +11,7 @@ import {
11
11
  import { WebFetchRefusedError } from '../../connector/web/types.js'
12
12
  import type { ToolContext } from '../../types/tool/index.js'
13
13
  import { defineTool } from '../defineTool.js'
14
+ import { clip } from './classifier-input.js'
14
15
 
15
16
  /**
16
17
  * The two web tools, and the reason they are one file.
@@ -151,6 +152,8 @@ export const WebFetchTool = defineTool({
151
152
  // preset — handing outbound reach to runs whose sandbox was never asked
152
153
  // to confine the network.
153
154
  category: 'network',
155
+ // The classifier judges the address and what is asked of it.
156
+ classifierInput: (input) => `${clip(input.url, 1000)}: ${clip(input.prompt, 300)}`,
154
157
  permissions: ['network_access'],
155
158
  // Reads a page and changes nothing HERE. That is not the same as
156
159
  // harmless — a GET can still act on a remote system — which is why the
@@ -6,6 +6,7 @@ import type { ToolFacts } from '../../types/tool/presentation.js'
6
6
  import { defineTool } from '../defineTool.js'
7
7
  import { resolveWithinAnyReal, toolRoots } from '../paths.js'
8
8
  import { atomicWriteFile } from './atomic-write-file.js'
9
+ import { clip } from './classifier-input.js'
9
10
  import { fingerprintContent, staleFileError } from './content-fingerprint.js'
10
11
  import { withFileMutationLock } from './file-mutation-lock.js'
11
12
  import { fileWriteResult } from './file-write-result.js'
@@ -93,6 +94,11 @@ export const WriteFileTool = defineTool({
93
94
  // Declared so a path outside the turn's roots can be reviewed before the
94
95
  // call runs, rather than refused after. See `ToolDefinition.pathArgument`.
95
96
  pathArgument: 'path',
97
+ // The classifier judges where a file is written and how much, not the body.
98
+ classifierInput: (input: WriteInput) => {
99
+ const body = input.content ?? input.newStr ?? ''
100
+ return `${input.path}: writes ${body.length} characters, starting ${JSON.stringify(clip(body, 300))}`
101
+ },
96
102
  permissions: ['file_write'],
97
103
  readOnly: false,
98
104
  destructive: true,
@@ -33,6 +33,8 @@ export interface DefineToolOptions<S extends z.ZodType> {
33
33
  capturesScreen?: boolean | ((input: z.infer<S>) => boolean)
34
34
  /** Whether this exact call always needs a person's approval; see {@link ToolDefinition.requiresApproval}. */
35
35
  requiresApproval?: boolean | ((input: z.infer<S>) => boolean)
36
+ /** What the auto-mode classifier is shown of this call; see {@link ToolDefinition.classifierInput}. */
37
+ classifierInput?: (input: z.infer<S>) => string
36
38
  /** Free-form filtering data, never sent to the model; see {@link ToolDefinition.metadata}. */
37
39
  metadata?: Readonly<Record<string, unknown>>
38
40
  /** Batch ordering boundary; see {@link ToolDefinition.executionBarrier}. */
@@ -186,6 +188,7 @@ export function defineTool<S extends z.ZodType>(
186
188
  : () => options.requiresApproval as boolean,
187
189
  }
188
190
  : {}),
191
+ ...(options.classifierInput !== undefined ? { classifierInput: options.classifierInput } : {}),
189
192
  ...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
190
193
 
191
194
  async execute(input: TInput, context: ToolContext): Promise<ToolResult> {
@@ -43,6 +43,13 @@ export interface AcpInitializeResult {
43
43
  readonly promptAttachments?: boolean
44
44
  /** True only when this host applies captured effort and review settings per prompt. */
45
45
  readonly promptOptions?: boolean
46
+ /**
47
+ * The review modes beyond the original five (`prompt`, `plan`,
48
+ * `accept-edits`, `auto`, `strict`) that this host applies per prompt:
49
+ * `bypass` and `classify`, when it supports them. Absent: it accepts only
50
+ * the original five, and refuses the others before a turn starts.
51
+ */
52
+ readonly promptPermissionModes?: readonly ('bypass' | 'classify')[]
46
53
  /** Optional, explicitly installed Namzu host methods; core ACP peers can ignore them. */
47
54
  readonly extensions?: readonly string[]
48
55
  readonly protocolVersion: number
@@ -119,6 +119,14 @@ export interface AnalysisFunction {
119
119
  readonly id: number
120
120
  /** `null` for an anonymous function with nothing to infer a name from. */
121
121
  readonly name: string | null
122
+ /**
123
+ * `name` qualified by what holds it, when it is a member: `Svc.init` for a
124
+ * method of the class `Svc` (a class expression assigned to a variable takes
125
+ * the variable's name), `obj.run` for a method of an object literal assigned
126
+ * to `obj`. Absent for a function that is not a member. `functionSource`
127
+ * accepts either spelling.
128
+ */
129
+ readonly qualifiedName?: string
122
130
  readonly kind: AnalysisFunctionKind
123
131
  /** Absolute path. */
124
132
  readonly path: string
@@ -136,6 +144,17 @@ export interface AnalysisSourceSlice {
136
144
  readonly chars: number
137
145
  /** `true` when `text` is shorter than `chars` because of the size cap. */
138
146
  readonly truncated: boolean
147
+ /**
148
+ * Present when the cut was made around a position rather than at the end:
149
+ * `text` is the function's first `headerChars` characters, a marker for
150
+ * what was left out, then the file's own characters `from` to `to`, which
151
+ * contain the position asked about. `from` and `to` are offsets into the file.
152
+ */
153
+ readonly window?: {
154
+ readonly headerChars: number
155
+ readonly from: number
156
+ readonly to: number
157
+ }
139
158
  }
140
159
 
141
160
  export interface IndexRequest extends AnalysisCallOptions {
@@ -222,6 +241,11 @@ export interface EnclosingFunctionRequest extends AnalysisCallOptions {
222
241
  export interface AnalysisEnclosingFunction {
223
242
  /** `null` when the position is not inside any function — a real answer. */
224
243
  readonly function: AnalysisFunction | null
244
+ /**
245
+ * When `function` is `null` because the position is in a class body outside
246
+ * every method (a field initializer, a `static {}` block): that class.
247
+ */
248
+ readonly inClass?: string
225
249
  /** The function's parents, innermost first. */
226
250
  readonly parents: readonly AnalysisFunction[]
227
251
  readonly source?: AnalysisSourceSlice
@@ -235,6 +259,11 @@ export interface FunctionSourceRequest extends AnalysisPaging {
235
259
  readonly path?: string
236
260
  readonly name?: string
237
261
  readonly maxChars?: number
262
+ /**
263
+ * A position in the function. When the source is longer than `maxChars`, the
264
+ * slice is cut around it instead of at the end, and always contains it.
265
+ */
266
+ readonly around?: { readonly line: number; readonly column?: number }
238
267
  }
239
268
 
240
269
  export interface AnalysisFunctionSource {
@@ -245,7 +274,11 @@ export interface AnalysisFunctionSource {
245
274
 
246
275
  export interface CallersOfRequest extends AnalysisPaging {
247
276
  readonly root: string
248
- /** The callee text: `foo`, `obj.foo`, `this.foo`. */
277
+ /**
278
+ * The callee text: `foo`, `obj.foo`, `this.foo`. A method's qualified name
279
+ * (`Svc.init`, as `functionSource` shows it) finds `this.init()` inside
280
+ * `Svc` as a resolved call, and every other `<expr>.init()` as `text-only`.
281
+ */
249
282
  readonly name: string
250
283
  /**
251
284
  * `file` looks in `path` only and is the default, because a callee matched
@@ -266,8 +299,19 @@ export interface AnalysisCallSite {
266
299
  readonly line: number
267
300
  readonly column: number
268
301
  readonly callee: string
269
- readonly kind: 'call' | 'new'
302
+ /**
303
+ * `call-via` is `f.call(…)`, `f.apply(…)` or `f.bind(…)` of the callee (a
304
+ * use, not necessarily a call); `tagged` is a tagged template.
305
+ */
306
+ readonly kind: 'call' | 'new' | 'call-via' | 'tagged'
270
307
  readonly argumentCount: number
308
+ /**
309
+ * Only when the request named a method by its qualified name: `resolved`
310
+ * for `this.m()` inside the class that declares it and for the qualified
311
+ * text itself, `text-only` for any other receiver, which may be an
312
+ * unrelated method of the same name.
313
+ */
314
+ readonly resolution?: 'resolved' | 'text-only'
271
315
  /** The function the call sits in; absent at the top level of a file. */
272
316
  readonly caller?: AnalysisFunction
273
317
  /** See {@link AnalysisLiteralHit.stale}. */
@@ -290,7 +334,11 @@ export type AnalysisModuleEdge =
290
334
  }
291
335
  | {
292
336
  readonly direction: 'export'
293
- readonly kind: 'named' | 'default' | 'reexport' | 'star'
337
+ /**
338
+ * `type` is a type-only export, `commonjs` is `module.exports` or
339
+ * `exports.x`, `assign` is `export =`.
340
+ */
341
+ readonly kind: 'named' | 'default' | 'reexport' | 'star' | 'type' | 'commonjs' | 'assign'
294
342
  readonly name: string
295
343
  readonly source?: string
296
344
  readonly line: number