@namzu/sdk 45.0.0 → 45.1.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 (230) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/authorization/gate.d.ts.map +1 -1
  3. package/dist/authorization/gate.js +12 -2
  4. package/dist/authorization/gate.js.map +1 -1
  5. package/dist/authorization/rules.d.ts.map +1 -1
  6. package/dist/authorization/rules.js +37 -6
  7. package/dist/authorization/rules.js.map +1 -1
  8. package/dist/authorization/shell-lexer.d.ts +14 -0
  9. package/dist/authorization/shell-lexer.d.ts.map +1 -1
  10. package/dist/authorization/shell-lexer.js +19 -6
  11. package/dist/authorization/shell-lexer.js.map +1 -1
  12. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  13. package/dist/bridge/a2a/mapper.js +2 -0
  14. package/dist/bridge/a2a/mapper.js.map +1 -1
  15. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  16. package/dist/bridge/sse/mapper.js +1 -0
  17. package/dist/bridge/sse/mapper.js.map +1 -1
  18. package/dist/directory/types.d.ts +2 -0
  19. package/dist/directory/types.d.ts.map +1 -1
  20. package/dist/directory/types.js.map +1 -1
  21. package/dist/manager/resident/outbox.d.ts +4 -4
  22. package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
  23. package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
  24. package/dist/prompt/coding-agent-doctrine.js +1 -0
  25. package/dist/prompt/coding-agent-doctrine.js.map +1 -1
  26. package/dist/public-runtime.d.ts +4 -1
  27. package/dist/public-runtime.d.ts.map +1 -1
  28. package/dist/public-runtime.js +11 -1
  29. package/dist/public-runtime.js.map +1 -1
  30. package/dist/public-tools.d.ts +4 -0
  31. package/dist/public-tools.d.ts.map +1 -1
  32. package/dist/public-tools.js +10 -0
  33. package/dist/public-tools.js.map +1 -1
  34. package/dist/public-types.d.ts +7 -1
  35. package/dist/public-types.d.ts.map +1 -1
  36. package/dist/runtime/query/declined.d.ts +12 -0
  37. package/dist/runtime/query/declined.d.ts.map +1 -0
  38. package/dist/runtime/query/declined.js +12 -0
  39. package/dist/runtime/query/declined.js.map +1 -0
  40. package/dist/runtime/query/executor.d.ts +3 -1
  41. package/dist/runtime/query/executor.d.ts.map +1 -1
  42. package/dist/runtime/query/executor.js +14 -2
  43. package/dist/runtime/query/executor.js.map +1 -1
  44. package/dist/runtime/query/index.d.ts.map +1 -1
  45. package/dist/runtime/query/index.js +1 -0
  46. package/dist/runtime/query/index.js.map +1 -1
  47. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  48. package/dist/runtime/query/iteration/index.js +7 -0
  49. package/dist/runtime/query/iteration/index.js.map +1 -1
  50. package/dist/runtime/query/iteration/phases/context.d.ts +6 -0
  51. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  52. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  53. package/dist/runtime/query/iteration/phases/handoff.d.ts +22 -0
  54. package/dist/runtime/query/iteration/phases/handoff.d.ts.map +1 -0
  55. package/dist/runtime/query/iteration/phases/handoff.js +65 -0
  56. package/dist/runtime/query/iteration/phases/handoff.js.map +1 -0
  57. package/dist/runtime/query/iteration/phases/index.d.ts +1 -0
  58. package/dist/runtime/query/iteration/phases/index.d.ts.map +1 -1
  59. package/dist/runtime/query/iteration/phases/index.js +1 -0
  60. package/dist/runtime/query/iteration/phases/index.js.map +1 -1
  61. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  62. package/dist/runtime/query/iteration/phases/tool-review.js +8 -3
  63. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  64. package/dist/runtime/query/resume-pending.d.ts.map +1 -1
  65. package/dist/runtime/query/resume-pending.js +3 -2
  66. package/dist/runtime/query/resume-pending.js.map +1 -1
  67. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  68. package/dist/runtime/query/review-policy.js +4 -3
  69. package/dist/runtime/query/review-policy.js.map +1 -1
  70. package/dist/schedules/cron.d.ts +21 -0
  71. package/dist/schedules/cron.d.ts.map +1 -0
  72. package/dist/schedules/cron.js +167 -0
  73. package/dist/schedules/cron.js.map +1 -0
  74. package/dist/schedules/describe.d.ts +14 -0
  75. package/dist/schedules/describe.d.ts.map +1 -0
  76. package/dist/schedules/describe.js +133 -0
  77. package/dist/schedules/describe.js.map +1 -0
  78. package/dist/schedules/errors.d.ts +11 -0
  79. package/dist/schedules/errors.d.ts.map +1 -0
  80. package/dist/schedules/errors.js +15 -0
  81. package/dist/schedules/errors.js.map +1 -0
  82. package/dist/schedules/evaluate.d.ts +35 -0
  83. package/dist/schedules/evaluate.d.ts.map +1 -0
  84. package/dist/schedules/evaluate.js +158 -0
  85. package/dist/schedules/evaluate.js.map +1 -0
  86. package/dist/schedules/index.d.ts +11 -0
  87. package/dist/schedules/index.d.ts.map +1 -0
  88. package/dist/schedules/index.js +8 -0
  89. package/dist/schedules/index.js.map +1 -0
  90. package/dist/schedules/next-fire.d.ts +50 -0
  91. package/dist/schedules/next-fire.d.ts.map +1 -0
  92. package/dist/schedules/next-fire.js +250 -0
  93. package/dist/schedules/next-fire.js.map +1 -0
  94. package/dist/schedules/spec.d.ts +30 -0
  95. package/dist/schedules/spec.d.ts.map +1 -0
  96. package/dist/schedules/spec.js +169 -0
  97. package/dist/schedules/spec.js.map +1 -0
  98. package/dist/schedules/types.d.ts +144 -0
  99. package/dist/schedules/types.d.ts.map +1 -0
  100. package/dist/schedules/types.js +11 -0
  101. package/dist/schedules/types.js.map +1 -0
  102. package/dist/schedules/tz.d.ts +44 -0
  103. package/dist/schedules/tz.d.ts.map +1 -0
  104. package/dist/schedules/tz.js +141 -0
  105. package/dist/schedules/tz.js.map +1 -0
  106. package/dist/skills/index.d.ts +1 -1
  107. package/dist/skills/index.d.ts.map +1 -1
  108. package/dist/skills/index.js +1 -1
  109. package/dist/skills/index.js.map +1 -1
  110. package/dist/skills/loader.d.ts +16 -3
  111. package/dist/skills/loader.d.ts.map +1 -1
  112. package/dist/skills/loader.js +48 -4
  113. package/dist/skills/loader.js.map +1 -1
  114. package/dist/tools/builtins/browser-url.d.ts +83 -0
  115. package/dist/tools/builtins/browser-url.d.ts.map +1 -0
  116. package/dist/tools/builtins/browser-url.js +240 -0
  117. package/dist/tools/builtins/browser-url.js.map +1 -0
  118. package/dist/tools/builtins/browser.d.ts +367 -0
  119. package/dist/tools/builtins/browser.d.ts.map +1 -0
  120. package/dist/tools/builtins/browser.js +704 -0
  121. package/dist/tools/builtins/browser.js.map +1 -0
  122. package/dist/tools/builtins/skill.d.ts.map +1 -1
  123. package/dist/tools/builtins/skill.js +15 -0
  124. package/dist/tools/builtins/skill.js.map +1 -1
  125. package/dist/tools/defineTool.d.ts +2 -0
  126. package/dist/tools/defineTool.d.ts.map +1 -1
  127. package/dist/tools/defineTool.js +1 -0
  128. package/dist/tools/defineTool.js.map +1 -1
  129. package/dist/tools/schedules/index.d.ts +5 -0
  130. package/dist/tools/schedules/index.d.ts.map +1 -0
  131. package/dist/tools/schedules/index.js +4 -0
  132. package/dist/tools/schedules/index.js.map +1 -0
  133. package/dist/tools/schedules/loop-tool.d.ts +14 -0
  134. package/dist/tools/schedules/loop-tool.d.ts.map +1 -0
  135. package/dist/tools/schedules/loop-tool.js +81 -0
  136. package/dist/tools/schedules/loop-tool.js.map +1 -0
  137. package/dist/tools/schedules/present.d.ts +16 -0
  138. package/dist/tools/schedules/present.d.ts.map +1 -0
  139. package/dist/tools/schedules/present.js +69 -0
  140. package/dist/tools/schedules/present.js.map +1 -0
  141. package/dist/tools/schedules/prompt-scan.d.ts +17 -0
  142. package/dist/tools/schedules/prompt-scan.d.ts.map +1 -0
  143. package/dist/tools/schedules/prompt-scan.js +92 -0
  144. package/dist/tools/schedules/prompt-scan.js.map +1 -0
  145. package/dist/tools/schedules/schedule-tool.d.ts +16 -0
  146. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -0
  147. package/dist/tools/schedules/schedule-tool.js +327 -0
  148. package/dist/tools/schedules/schedule-tool.js.map +1 -0
  149. package/dist/tools/schedules/types.d.ts +184 -0
  150. package/dist/tools/schedules/types.d.ts.map +1 -0
  151. package/dist/tools/schedules/types.js +11 -0
  152. package/dist/tools/schedules/types.js.map +1 -0
  153. package/dist/types/authorization/index.d.ts +86 -9
  154. package/dist/types/authorization/index.d.ts.map +1 -1
  155. package/dist/types/authorization/index.js +11 -1
  156. package/dist/types/authorization/index.js.map +1 -1
  157. package/dist/types/browser/index.d.ts +280 -0
  158. package/dist/types/browser/index.d.ts.map +1 -0
  159. package/dist/types/browser/index.js +12 -0
  160. package/dist/types/browser/index.js.map +1 -0
  161. package/dist/types/session/events.d.ts +6 -0
  162. package/dist/types/session/events.d.ts.map +1 -1
  163. package/dist/types/session/events.js.map +1 -1
  164. package/dist/types/session/records.d.ts +23 -0
  165. package/dist/types/session/records.d.ts.map +1 -1
  166. package/dist/types/session/records.js +9 -0
  167. package/dist/types/session/records.js.map +1 -1
  168. package/dist/types/tool/index.d.ts +41 -0
  169. package/dist/types/tool/index.d.ts.map +1 -1
  170. package/dist/types/tool/index.js.map +1 -1
  171. package/dist/types/tool/presentation.d.ts +7 -0
  172. package/dist/types/tool/presentation.d.ts.map +1 -1
  173. package/dist/utils/frontmatter.d.ts +18 -2
  174. package/dist/utils/frontmatter.d.ts.map +1 -1
  175. package/dist/utils/frontmatter.js +13 -3
  176. package/dist/utils/frontmatter.js.map +1 -1
  177. package/dist/utils/id.d.ts +8 -0
  178. package/dist/utils/id.d.ts.map +1 -1
  179. package/dist/utils/id.js +12 -0
  180. package/dist/utils/id.js.map +1 -1
  181. package/package.json +1 -1
  182. package/src/authorization/gate.ts +14 -2
  183. package/src/authorization/rules.ts +37 -7
  184. package/src/authorization/shell-lexer.ts +36 -6
  185. package/src/bridge/a2a/mapper.ts +2 -0
  186. package/src/bridge/sse/mapper.ts +1 -0
  187. package/src/directory/types.ts +2 -0
  188. package/src/prompt/coding-agent-doctrine.ts +1 -0
  189. package/src/public-runtime.ts +28 -0
  190. package/src/public-tools.ts +35 -0
  191. package/src/public-types.ts +50 -0
  192. package/src/runtime/query/declined.ts +12 -0
  193. package/src/runtime/query/executor.ts +17 -1
  194. package/src/runtime/query/index.ts +1 -0
  195. package/src/runtime/query/iteration/index.ts +8 -0
  196. package/src/runtime/query/iteration/phases/context.ts +6 -0
  197. package/src/runtime/query/iteration/phases/handoff.ts +74 -0
  198. package/src/runtime/query/iteration/phases/index.ts +1 -0
  199. package/src/runtime/query/iteration/phases/tool-review.ts +11 -4
  200. package/src/runtime/query/resume-pending.ts +3 -2
  201. package/src/runtime/query/review-policy.ts +4 -3
  202. package/src/schedules/cron.ts +202 -0
  203. package/src/schedules/describe.ts +138 -0
  204. package/src/schedules/errors.ts +15 -0
  205. package/src/schedules/evaluate.ts +178 -0
  206. package/src/schedules/index.ts +18 -0
  207. package/src/schedules/next-fire.ts +257 -0
  208. package/src/schedules/spec.ts +210 -0
  209. package/src/schedules/types.ts +163 -0
  210. package/src/schedules/tz.ts +155 -0
  211. package/src/skills/index.ts +1 -1
  212. package/src/skills/loader.ts +55 -4
  213. package/src/tools/builtins/browser-url.ts +251 -0
  214. package/src/tools/builtins/browser.ts +817 -0
  215. package/src/tools/builtins/skill.ts +18 -0
  216. package/src/tools/defineTool.ts +3 -0
  217. package/src/tools/schedules/index.ts +4 -0
  218. package/src/tools/schedules/loop-tool.ts +85 -0
  219. package/src/tools/schedules/present.ts +86 -0
  220. package/src/tools/schedules/prompt-scan.ts +96 -0
  221. package/src/tools/schedules/schedule-tool.ts +376 -0
  222. package/src/tools/schedules/types.ts +197 -0
  223. package/src/types/authorization/index.ts +60 -2
  224. package/src/types/browser/index.ts +341 -0
  225. package/src/types/session/events.ts +6 -0
  226. package/src/types/session/records.ts +10 -0
  227. package/src/types/tool/index.ts +42 -0
  228. package/src/types/tool/presentation.ts +7 -0
  229. package/src/utils/frontmatter.ts +29 -3
  230. package/src/utils/id.ts +14 -0
@@ -256,6 +256,24 @@ export const SkillTool = defineTool({
256
256
  destructive: false,
257
257
  concurrencySafe: true,
258
258
 
259
+ // The body is instructions for the model, often a hundred lines; the
260
+ // person needs the row that says which skill was read, not the text.
261
+ presentCall(input: SkillInput) {
262
+ const name = typeof input?.name === 'string' ? input.name : undefined
263
+ return {
264
+ kind: 'generic',
265
+ presentation: 'activity',
266
+ label:
267
+ name === undefined
268
+ ? input?.cursor === undefined
269
+ ? 'List skills'
270
+ : 'List more skills'
271
+ : `Read skill ${name}${input.cursor === undefined ? '' : ' (continued)'}`,
272
+ }
273
+ },
274
+ presentResult: (_input: SkillInput, result) =>
275
+ result.success ? { kind: 'generic', label: 'read', visibility: 'hidden' } : undefined,
276
+
259
277
  async execute(input: SkillInput, context) {
260
278
  if (!context.skills) {
261
279
  return {
@@ -65,6 +65,8 @@ export interface DefineToolOptions<S extends z.ZodType> {
65
65
  commandDialect?: ToolDefinition['commandDialect']
66
66
  /** The argument holding a filesystem path; see {@link ToolDefinition.pathArgument}. */
67
67
  pathArgument?: string
68
+ /** The argument holding a canonical URL; see {@link ToolDefinition.urlArgument}. */
69
+ urlArgument?: string
68
70
  /** The argument asking to leave the sandbox; see {@link ToolDefinition.sandboxEscapeArgument}. */
69
71
  sandboxEscapeArgument?: string
70
72
  execute(input: z.infer<S>, context: ToolContext): Promise<ToolResult>
@@ -117,6 +119,7 @@ export function defineTool<S extends z.ZodType>(
117
119
  ...(options.commandArgument !== undefined ? { commandArgument: options.commandArgument } : {}),
118
120
  ...(options.commandDialect !== undefined ? { commandDialect: options.commandDialect } : {}),
119
121
  ...(options.pathArgument !== undefined ? { pathArgument: options.pathArgument } : {}),
122
+ ...(options.urlArgument !== undefined ? { urlArgument: options.urlArgument } : {}),
120
123
  ...(options.sandboxEscapeArgument !== undefined
121
124
  ? { sandboxEscapeArgument: options.sandboxEscapeArgument }
122
125
  : {}),
@@ -0,0 +1,4 @@
1
+ export { buildScheduleTools, SCHEDULE_TOOL_NAME } from './schedule-tool.js'
2
+ export { buildSessionLoopTools, SESSION_LOOP_TOOL_NAME } from './loop-tool.js'
3
+ export { revealHiddenCharacters, scanSchedulePrompt } from './prompt-scan.js'
4
+ export type * from './types.js'
@@ -0,0 +1,85 @@
1
+ import { z } from 'zod'
2
+ import type { ToolDefinition } from '../../types/tool/index.js'
3
+ import { defineTool } from '../defineTool.js'
4
+ import { presentLoopCall, presentLoopResult } from './present.js'
5
+ import type { SessionLoopHost } from './types.js'
6
+
7
+ export const SESSION_LOOP_TOOL_NAME = 'session_loop'
8
+
9
+ const inputSchema = z.object({
10
+ action: z.enum(['create', 'list', 'delete']),
11
+ interval: z
12
+ .string()
13
+ .optional()
14
+ .describe('create: how often, e.g. "5m", "1h", or a five-field cron expression'),
15
+ prompt: z.string().min(1).max(4_000).optional().describe('create: the message to send each time'),
16
+ id: z.string().optional().describe('delete: the loop id, or "all"'),
17
+ })
18
+
19
+ /**
20
+ * The `session_loop` tool: re-send a prompt to THIS conversation on an
21
+ * interval, between turns, while the session is open.
22
+ *
23
+ * `create` is an ordinary reviewed call — not exempt from review — so in
24
+ * `prompt` mode the operator sees it before a loop exists; a loop a model
25
+ * created is labelled as such wherever it is shown. `list` and `delete` only
26
+ * read or remove loops.
27
+ */
28
+ export function buildSessionLoopTools(host: SessionLoopHost): ToolDefinition[] {
29
+ return [
30
+ defineTool({
31
+ name: SESSION_LOOP_TOOL_NAME,
32
+ description:
33
+ 'Re-send a prompt to this conversation on an interval while the session stays open (between turns only; stops when the session closes; expires after 7 days). Use only when the user asks for something to repeat. Minimum interval one minute.',
34
+ inputSchema,
35
+ category: 'custom',
36
+ permissions: [],
37
+ readOnly: (input) => input.action !== 'create',
38
+ destructive: false,
39
+ concurrencySafe: false,
40
+ presentCall: presentLoopCall,
41
+ presentResult: presentLoopResult,
42
+ async execute(input) {
43
+ if (input.action === 'list') {
44
+ const loops = host.list()
45
+ return {
46
+ success: true,
47
+ output:
48
+ loops.length === 0
49
+ ? 'No loops in this session.'
50
+ : loops.map((l) => `${l.id} · ${l.schedule} · ${l.prompt.slice(0, 80)}`).join('\n'),
51
+ data: { loops },
52
+ }
53
+ }
54
+ if (input.action === 'delete') {
55
+ if (!input.id) return { success: false, output: '', error: 'delete needs id (or "all").' }
56
+ const stopped = await host.delete(input.id)
57
+ return stopped === 0
58
+ ? { success: false, output: '', error: `No loop has id "${input.id}".` }
59
+ : { success: true, output: `Stopped ${stopped} loop${stopped === 1 ? '' : 's'}.` }
60
+ }
61
+ if (!input.interval || !input.prompt) {
62
+ return { success: false, output: '', error: 'create needs interval and prompt.' }
63
+ }
64
+ try {
65
+ const loop = await host.create({
66
+ interval: input.interval,
67
+ prompt: input.prompt,
68
+ createdBy: 'model',
69
+ })
70
+ return {
71
+ success: true,
72
+ output: `Loop ${loop.id} created: ${loop.schedule}. The operator can stop it with /loop stop ${loop.id}.`,
73
+ data: { id: loop.id },
74
+ }
75
+ } catch (error) {
76
+ return {
77
+ success: false,
78
+ output: '',
79
+ error: error instanceof Error ? error.message : String(error),
80
+ }
81
+ }
82
+ },
83
+ }),
84
+ ]
85
+ }
@@ -0,0 +1,86 @@
1
+ import type { ToolResult } from '../../types/tool/index.js'
2
+ import type { ToolCallView, ToolResultView } from '../../types/tool/presentation.js'
3
+
4
+ /**
5
+ * How the schedule tools read to a person: in words, never ids or JSON.
6
+ */
7
+
8
+ const MAX = 80
9
+
10
+ function oneLine(value: unknown): string {
11
+ if (typeof value !== 'string') return ''
12
+ const flat = value.replace(/\s+/g, ' ').trim()
13
+ return flat.length > MAX ? `${flat.slice(0, MAX - 1)}…` : flat
14
+ }
15
+
16
+ function activity(label: string): ToolCallView {
17
+ return { kind: 'generic', presentation: 'activity', label }
18
+ }
19
+
20
+ const HIDDEN: ToolResultView = { kind: 'generic', label: '', visibility: 'hidden' }
21
+
22
+ export function presentScheduleCall(input: {
23
+ readonly action?: unknown
24
+ readonly name?: unknown
25
+ readonly when?: unknown
26
+ readonly job?: unknown
27
+ }): ToolCallView {
28
+ switch (input.action) {
29
+ case 'create': {
30
+ const name = oneLine(input.name)
31
+ const when = oneLine(input.when)
32
+ return activity(`Propose scheduled job${name ? ` · ${name}` : ''}${when ? ` · ${when}` : ''}`)
33
+ }
34
+ case 'list':
35
+ return activity('List scheduled jobs')
36
+ case 'pause':
37
+ return activity(`Pause scheduled job · ${oneLine(input.job)}`)
38
+ case 'resume':
39
+ return activity(`Resume scheduled job · ${oneLine(input.job)}`)
40
+ case 'delete':
41
+ return activity(`Delete scheduled job · ${oneLine(input.job)}`)
42
+ default:
43
+ return activity('Scheduled jobs')
44
+ }
45
+ }
46
+
47
+ /** The operator answered no on the tool's own screen: not a failure. */
48
+ function operatorCancelled(result: ToolResult): boolean {
49
+ const data = result.data as { cancelled?: unknown } | undefined
50
+ return data?.cancelled === true
51
+ }
52
+
53
+ export function presentScheduleResult(input: unknown, result: ToolResult): ToolResultView {
54
+ if (!result.success && operatorCancelled(result)) {
55
+ const action = (input as { action?: unknown } | null)?.action
56
+ return {
57
+ kind: 'generic',
58
+ outcome: 'cancelled',
59
+ label: action === 'create' ? 'Cancelled — no job was created' : 'Cancelled — nothing changed',
60
+ }
61
+ }
62
+ if (!result.success) return { kind: 'generic', label: oneLine(result.error) || 'Not done' }
63
+ return HIDDEN
64
+ }
65
+
66
+ export function presentLoopCall(input: {
67
+ readonly action?: unknown
68
+ readonly interval?: unknown
69
+ readonly prompt?: unknown
70
+ }): ToolCallView {
71
+ switch (input.action) {
72
+ case 'create':
73
+ return activity(`Loop ${oneLine(input.interval)} · ${oneLine(input.prompt)}`)
74
+ case 'list':
75
+ return activity('List loops')
76
+ case 'delete':
77
+ return activity('Stop loop')
78
+ default:
79
+ return activity('Loops')
80
+ }
81
+ }
82
+
83
+ export function presentLoopResult(_input: unknown, result: ToolResult): ToolResultView {
84
+ if (!result.success) return { kind: 'generic', label: oneLine(result.error) || 'Not done' }
85
+ return HIDDEN
86
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A tripwire over a scheduled prompt, run when the job is proposed.
3
+ *
4
+ * A scheduled prompt runs later with nobody watching, so a prompt that hides
5
+ * characters, tells the model to ignore its instructions, or reads secrets and
6
+ * sends them somewhere deserves a second look before anyone confirms it.
7
+ * Findings are shown to the person confirming; they never block on their own,
8
+ * because the operator may mean exactly what they wrote.
9
+ */
10
+
11
+ /** Characters that render as nothing or reorder what is shown. */
12
+ /** Invisible and direction-changing code point ranges, inclusive. */
13
+ const INVISIBLE_RANGES: readonly (readonly [number, number])[] = [
14
+ [0x200b, 0x200f],
15
+ [0x202a, 0x202e],
16
+ [0x2060, 0x2064],
17
+ [0x2066, 0x2069],
18
+ [0xfeff, 0xfeff],
19
+ [0x00ad, 0x00ad],
20
+ ]
21
+
22
+ function isInvisible(code: number): boolean {
23
+ return INVISIBLE_RANGES.some(([lo, hi]) => code >= lo && code <= hi)
24
+ }
25
+
26
+ function hasInvisible(text: string): boolean {
27
+ for (const ch of text) if (isInvisible(ch.codePointAt(0) ?? 0)) return true
28
+ return false
29
+ }
30
+
31
+ /** C0 controls other than tab, newline and carriage return, and DEL. */
32
+ function isControl(code: number): boolean {
33
+ return (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f
34
+ }
35
+
36
+ function hasControl(text: string): boolean {
37
+ for (let i = 0; i < text.length; i++) if (isControl(text.charCodeAt(i))) return true
38
+ return false
39
+ }
40
+
41
+ const DIRECTIVES: readonly RegExp[] = [
42
+ /\bignore (all |any )?(the )?(previous|prior|above|earlier) (instructions|rules|messages)\b/i,
43
+ /\bdisregard (all |any )?(the )?(previous|prior|above|system) /i,
44
+ /\b(reveal|print|show|repeat) (your|the) (system prompt|instructions)\b/i,
45
+ /\byou are no longer\b/i,
46
+ ]
47
+
48
+ const SECRET_READS: readonly RegExp[] = [
49
+ /~?\/?\.ssh\b|\bid_(rsa|ed25519|ecdsa)\b/i,
50
+ /\.aws\/credentials|\.config\/gcloud|\.kube\/config|\.netrc\b|\.npmrc\b|\.pypirc\b/i,
51
+ /\bcredentials\.json\b|\.env\b/i,
52
+ /\b(printenv|env)\b\s*($|[|;&>])/i,
53
+ /\b[A-Z0-9_]*(API_KEY|SECRET|TOKEN|PASSWORD)\b/,
54
+ ]
55
+
56
+ const EXFILTRATION: readonly RegExp[] = [
57
+ /\b(curl|wget)\b[^\n|]*\|\s*(ba|z|da)?sh\b/i,
58
+ /\bcurl\b[^\n]*\s(-d|--data(-binary|-raw)?|-F|--form|-T|--upload-file|-X\s*(POST|PUT))\b/i,
59
+ /\b(nc|ncat|netcat|socat)\b\s+\S+\s+\d+/i,
60
+ /\bbase64\b[^\n]*\|\s*(curl|wget|nc)\b/i,
61
+ ]
62
+
63
+ /** What the scan found, as sentences for the confirmation screen. Empty when nothing. */
64
+ export function scanSchedulePrompt(prompt: string): string[] {
65
+ const findings: string[] = []
66
+ if (hasInvisible(prompt)) {
67
+ findings.push('The prompt contains invisible or direction-changing characters.')
68
+ }
69
+ if (hasControl(prompt)) findings.push('The prompt contains control characters.')
70
+ if (DIRECTIVES.some((re) => re.test(prompt))) {
71
+ findings.push('The prompt tells the model to ignore or reveal its instructions.')
72
+ }
73
+ if (SECRET_READS.some((re) => re.test(prompt))) {
74
+ findings.push('The prompt mentions credentials, keys or secret files.')
75
+ }
76
+ if (EXFILTRATION.some((re) => re.test(prompt))) {
77
+ findings.push('The prompt pipes a download into a shell or sends data to a remote address.')
78
+ }
79
+ return findings
80
+ }
81
+
82
+ /**
83
+ * The prompt with every invisible and control character made visible as
84
+ * `<U+XXXX>`, so the person confirming sees what the model will read.
85
+ */
86
+ export function revealHiddenCharacters(prompt: string): string {
87
+ let out = ''
88
+ for (const ch of prompt) {
89
+ const code = ch.codePointAt(0) ?? 0
90
+ out +=
91
+ isControl(code) || isInvisible(code)
92
+ ? `<U+${code.toString(16).toUpperCase().padStart(4, '0')}>`
93
+ : ch
94
+ }
95
+ return out
96
+ }
@@ -0,0 +1,376 @@
1
+ import { z } from 'zod'
2
+ import type { ToolContext, ToolDefinition, ToolResult } from '../../types/tool/index.js'
3
+ import { canonicalizeBrowserSitePattern } from '../builtins/browser-url.js'
4
+ import { defineTool } from '../defineTool.js'
5
+ import { presentScheduleCall, presentScheduleResult } from './present.js'
6
+ import { scanSchedulePrompt } from './prompt-scan.js'
7
+ import type {
8
+ ScheduleBrowserGrant,
9
+ ScheduleBrowserSiteLevel,
10
+ ScheduleJobDraft,
11
+ ScheduleToolHost,
12
+ } from './types.js'
13
+
14
+ export const SCHEDULE_TOOL_NAME = 'schedule'
15
+
16
+ /**
17
+ * A person reads the whole proposal — the prompt, the rules, the schedule,
18
+ * the credential source — before answering `create`, `resume` or `delete`.
19
+ * The executor's `DEFAULT_TOOL_TIMEOUT_MS` (two minutes) is sized for a tool
20
+ * call, not a person on the other end of a screen, and would abandon the
21
+ * call out from under them mid-read. `save_skill`
22
+ * (`packages/cli/src/skills/save.ts`) waits on the same kind of answer and
23
+ * uses the same thirty minutes, for the same reason.
24
+ */
25
+ const OPERATOR_CONFIRM_TIMEOUT_MS = 30 * 60_000
26
+
27
+ const EFFECT = z.enum(['allow', 'ask', 'deny'])
28
+ const NETWORK_TOOLS = ['web_fetch', 'web_search']
29
+ const BROWSER_PROFILE = /^[a-z0-9][a-z0-9-]{0,62}$/
30
+
31
+ const inputSchema = z.object({
32
+ action: z
33
+ .enum(['create', 'list', 'pause', 'resume', 'delete'])
34
+ .describe('What to do. create, resume and delete ask the operator first.'),
35
+ name: z
36
+ .string()
37
+ .regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
38
+ .optional()
39
+ .describe('create: job name, lowercase letters, digits and dashes'),
40
+ prompt: z.string().min(1).max(20_000).optional().describe('create: what the run is asked to do'),
41
+ when: z
42
+ .string()
43
+ .optional()
44
+ .describe('create: "every 30m", "0 9 * * 1-5" (cron), "at 2026-09-24 09:00", "in 2h"'),
45
+ folder: z
46
+ .string()
47
+ .optional()
48
+ .describe("create: folder to run in; leave unset for the session's unless the user named one"),
49
+ tz: z
50
+ .string()
51
+ .optional()
52
+ .describe(
53
+ "create: IANA time zone; leave unset for the operator's own zone unless the user named another",
54
+ ),
55
+ permissions: z
56
+ .object({
57
+ preset: z
58
+ .enum(['read-only', 'edit-in-folder'])
59
+ .optional()
60
+ .describe(
61
+ 'read-only: read/glob/grep/ls only. edit-in-folder: also edit and write, bash asks',
62
+ ),
63
+ unmatched: z
64
+ .enum(['park', 'deny'])
65
+ .describe('A call no rule covers: park (wait for the operator) or deny'),
66
+ execution: z
67
+ .enum(['host', 'sandbox'])
68
+ .optional()
69
+ .describe(
70
+ 'Where commands run; leave unset (this machine) unless the user asked for a sandbox',
71
+ ),
72
+ rules: z
73
+ .record(z.string(), z.union([EFFECT, z.record(z.string(), EFFECT)]))
74
+ .optional()
75
+ .describe('Extra rules, e.g. {"bash": {"npm test*": "allow"}}'),
76
+ browser: z
77
+ .object({
78
+ profile: z
79
+ .string()
80
+ .regex(BROWSER_PROFILE)
81
+ .describe('Browser profile the operator signed in with, e.g. "work"'),
82
+ sites: z
83
+ .record(z.string(), z.enum(['read', 'ask', 'act']))
84
+ .describe(
85
+ 'Site to level, e.g. {"https://github.com": "read"}. read: open and read only; ask: changes wait for the operator; act: changes run unasked. Unlisted sites are denied; there is no "*".',
86
+ ),
87
+ headed: z.boolean().optional().describe('Show the browser window; default no window'),
88
+ })
89
+ .optional()
90
+ .describe('Browser access; omit for none'),
91
+ })
92
+ .optional()
93
+ .describe('create: REQUIRED explicit permission set; there is no default'),
94
+ budget: z
95
+ .object({
96
+ maxIterations: z
97
+ .number()
98
+ .int()
99
+ .positive()
100
+ .optional()
101
+ .describe(
102
+ 'Model steps one run may take (each model call with its tool calls is one step), not how many times the job runs. Omit for the default; a browser task takes 10 or more.',
103
+ ),
104
+ tokenBudget: z
105
+ .number()
106
+ .int()
107
+ .positive()
108
+ .optional()
109
+ .describe(
110
+ 'Tokens one run may spend in total. Every model call resends the whole prompt (often 10,000-30,000 tokens each), so a run needs far more than its answer; omit for the default.',
111
+ ),
112
+ timeoutMs: z
113
+ .number()
114
+ .int()
115
+ .positive()
116
+ .optional()
117
+ .describe('Wall clock of one run, in milliseconds'),
118
+ })
119
+ .optional()
120
+ .describe('Limits of ONE run; omit to use the defaults'),
121
+ job: z.string().optional().describe('pause/resume/delete: job name'),
122
+ allFolders: z.boolean().optional().describe('list: include jobs of other folders (names only)'),
123
+ })
124
+
125
+ type Input = z.infer<typeof inputSchema>
126
+
127
+ function refuse(error: string): ToolResult {
128
+ return { success: false, output: '', error }
129
+ }
130
+
131
+ function effectsOf(rule: unknown): string[] {
132
+ if (typeof rule === 'string') return [rule]
133
+ if (rule && typeof rule === 'object') return Object.values(rule as Record<string, string>)
134
+ return []
135
+ }
136
+
137
+ /**
138
+ * Refuse a network tool beside a shell that runs on the host: a scheduled run
139
+ * that can both read the machine and reach the internet, unattended, is the
140
+ * exfiltration shape, and only the operator can choose it.
141
+ */
142
+ function networkWithHostShell(draft: ScheduleJobDraft): boolean {
143
+ const rules = draft.permissions.rules ?? {}
144
+ const network =
145
+ draft.permissions.browser !== undefined ||
146
+ NETWORK_TOOLS.some((t) => effectsOf(rules[t]).some((e) => e === 'allow' || e === 'ask'))
147
+ if (!network) return false
148
+ const bashEffects = effectsOf(rules.bash)
149
+ // The read-only preset denies bash; its rules are expanded by the host,
150
+ // so the draft carries only its name.
151
+ const shellPossible =
152
+ bashEffects.length > 0
153
+ ? bashEffects.some((e) => e !== 'deny')
154
+ : draft.permissions.preset !== 'read-only' && draft.permissions.unmatched !== 'deny'
155
+ return shellPossible && (draft.permissions.execution ?? 'host') === 'host'
156
+ }
157
+
158
+ /**
159
+ * The browser grant with its site keys canonicalised, or why it is refused.
160
+ * Keys are rewritten so the host, the confirmation and the compiled rules
161
+ * all see one spelling of each site.
162
+ */
163
+ function browserGrant(
164
+ host: ScheduleToolHost,
165
+ proposed: NonNullable<NonNullable<Input['permissions']>['browser']>,
166
+ ): { ok: true; grant: ScheduleBrowserGrant } | { ok: false; error: string } {
167
+ if (host.browserGrants !== true) {
168
+ return {
169
+ ok: false,
170
+ error:
171
+ 'This host cannot give a scheduled job browser access. Propose the job without permissions.browser, or ask the operator to add it with `namzu schedule add`.',
172
+ }
173
+ }
174
+ const entries = Object.entries(proposed.sites)
175
+ if (entries.length === 0) {
176
+ return {
177
+ ok: false,
178
+ error:
179
+ 'permissions.browser.sites is empty; list each site the run may open, e.g. {"https://github.com": "read"}.',
180
+ }
181
+ }
182
+ const sites: Record<string, ScheduleBrowserSiteLevel> = {}
183
+ for (const [key, level] of entries) {
184
+ if (key.trim() === '*') {
185
+ return {
186
+ ok: false,
187
+ error:
188
+ 'A scheduled job cannot grant every site ("*"); unlisted sites are always denied. List each site.',
189
+ }
190
+ }
191
+ const verdict = canonicalizeBrowserSitePattern(key)
192
+ if (!verdict.ok) return { ok: false, error: `permissions.browser.sites: ${verdict.reason}` }
193
+ if (verdict.pattern in sites && sites[verdict.pattern] !== level) {
194
+ return {
195
+ ok: false,
196
+ error: `permissions.browser.sites names ${verdict.pattern} twice with different levels.`,
197
+ }
198
+ }
199
+ sites[verdict.pattern] = level
200
+ }
201
+ return {
202
+ ok: true,
203
+ grant: {
204
+ profile: proposed.profile,
205
+ sites,
206
+ ...(proposed.headed !== undefined ? { headed: proposed.headed } : {}),
207
+ },
208
+ }
209
+ }
210
+
211
+ async function create(
212
+ host: ScheduleToolHost,
213
+ input: Input,
214
+ signal: AbortSignal | undefined,
215
+ ): Promise<ToolResult> {
216
+ const missing = (['name', 'prompt', 'when', 'permissions'] as const).filter(
217
+ (k) => input[k] === undefined,
218
+ )
219
+ if (missing.length > 0) {
220
+ return refuse(
221
+ `create needs ${missing.join(', ')}. permissions is required: propose an explicit set (a preset and/or rules, plus unmatched).`,
222
+ )
223
+ }
224
+ const proposed = input.permissions as NonNullable<Input['permissions']>
225
+ if (!proposed.preset && !proposed.rules && !proposed.browser) {
226
+ return refuse(
227
+ 'permissions needs a preset, rules or a browser grant; an empty permission set is not a choice.',
228
+ )
229
+ }
230
+ let permissions: ScheduleJobDraft['permissions'] = proposed
231
+ if (proposed.browser) {
232
+ const grant = browserGrant(host, proposed.browser)
233
+ if (!grant.ok) return refuse(grant.error)
234
+ permissions = { ...proposed, browser: grant.grant }
235
+ }
236
+ const draft: ScheduleJobDraft = {
237
+ name: input.name as string,
238
+ prompt: input.prompt as string,
239
+ when: input.when as string,
240
+ ...(input.folder !== undefined ? { folder: input.folder } : {}),
241
+ ...(input.tz !== undefined ? { tz: input.tz } : {}),
242
+ permissions,
243
+ ...(input.budget ? { budget: input.budget } : {}),
244
+ }
245
+ if (networkWithHostShell(draft)) {
246
+ return refuse(
247
+ 'A scheduled job proposed here cannot combine web or browser access with a shell on the host. Deny bash, use execution "sandbox", or ask the operator to create it with `namzu schedule add`.',
248
+ )
249
+ }
250
+ let preview: Awaited<ReturnType<ScheduleToolHost['preview']>>
251
+ try {
252
+ preview = await host.preview(draft)
253
+ } catch (error) {
254
+ return refuse(error instanceof Error ? error.message : String(error))
255
+ }
256
+ let answer: string
257
+ try {
258
+ answer = await host.confirm(
259
+ {
260
+ preview,
261
+ promptFindings: scanSchedulePrompt(preview.prompt),
262
+ proposedBy: 'model',
263
+ },
264
+ signal,
265
+ )
266
+ } catch {
267
+ answer = 'cancel'
268
+ }
269
+ // The deadline or the turn can settle `host.confirm` from underneath a
270
+ // host that resolves optimistically on its own close; never create on an
271
+ // answer that arrived after the operator was no longer being asked.
272
+ if (signal?.aborted) answer = 'cancel'
273
+ if (answer !== 'create' && answer !== 'create-paused') {
274
+ return {
275
+ success: false,
276
+ output: '',
277
+ error: 'The operator did not confirm the job, so it was not created.',
278
+ data: { cancelled: true },
279
+ }
280
+ }
281
+ const created = await host.create(draft, preview, { paused: answer === 'create-paused' })
282
+ const said =
283
+ answer === 'create-paused'
284
+ ? `Job "${created.name}" was created paused. The operator can resume it with /schedule.`
285
+ : `Job "${created.name}" was created. It runs ${preview.schedule}, with nobody watching; results arrive as a notification and a session.`
286
+ return {
287
+ success: true,
288
+ output: created.note ? `${said} ${created.note}` : said,
289
+ data: { name: created.name, paused: answer === 'create-paused' },
290
+ }
291
+ }
292
+
293
+ async function list(host: ScheduleToolHost, input: Input): Promise<ToolResult> {
294
+ const jobs = await host.list({ allFolders: input.allFolders === true })
295
+ if (jobs.length === 0) return { success: true, output: 'No scheduled jobs.', data: { jobs: [] } }
296
+ const lines = jobs.map(
297
+ (j) =>
298
+ `${j.name} · ${j.state} · ${j.schedule}${j.nextFireAt ? ` · next ${j.nextFireAt}` : ''}${j.lastStatus ? ` · last ${j.lastStatus}` : ''} · ${j.folder}`,
299
+ )
300
+ return { success: true, output: lines.join('\n'), data: { jobs } }
301
+ }
302
+
303
+ async function lifecycle(
304
+ host: ScheduleToolHost,
305
+ input: Input,
306
+ action: 'pause' | 'resume' | 'delete',
307
+ signal: AbortSignal | undefined,
308
+ ): Promise<ToolResult> {
309
+ if (!input.job) return refuse(`${action} needs job (the job's name).`)
310
+ const job = await host.find(input.job)
311
+ if (!job) return refuse(`No scheduled job is named "${input.job}".`)
312
+ if (action !== 'pause') {
313
+ let confirmed = false
314
+ try {
315
+ confirmed = await host.confirmAction(job, action, signal)
316
+ } catch {
317
+ confirmed = false
318
+ }
319
+ if (signal?.aborted) confirmed = false
320
+ if (!confirmed)
321
+ return {
322
+ ...refuse(`The operator did not confirm; "${job.name}" was not changed.`),
323
+ data: { cancelled: true },
324
+ }
325
+ }
326
+ if (action === 'pause') await host.pause(job.name)
327
+ else if (action === 'resume') await host.resume(job.name)
328
+ else await host.delete(job.name)
329
+ const verb = action === 'pause' ? 'paused' : action === 'resume' ? 'resumed' : 'deleted'
330
+ return { success: true, output: `Job "${job.name}" ${verb}.`, data: { name: job.name, action } }
331
+ }
332
+
333
+ /**
334
+ * The `schedule` tool: create, list, pause, resume and delete the operator's
335
+ * scheduled jobs from a conversation.
336
+ *
337
+ * Creation, resuming and deleting are confirmed by a person through the host
338
+ * (`ScheduleToolHost.confirm`), on a screen the host draws from its own
339
+ * computation. The model cannot propose that uncovered calls run without
340
+ * asking, nor web access beside a host shell. Register it only where a person
341
+ * is present to confirm: never in a headless run, a scheduled run or a
342
+ * sub-agent.
343
+ */
344
+ export function buildScheduleTools(host: ScheduleToolHost): ToolDefinition[] {
345
+ return [
346
+ defineTool({
347
+ name: SCHEDULE_TOOL_NAME,
348
+ description:
349
+ "Manage the operator's scheduled jobs: prompts that run later in a folder, with nobody watching, under an explicit permission set. Use it only when the user asks for something to happen on a schedule. create, resume and delete are confirmed by the operator; pause is not. A job needs name, prompt, when and permissions (unmatched: park or deny, plus a preset, rules or a browser grant). Leave every other field (folder, tz, execution, budget, headed) unset unless the user asked for it: the defaults are the operator's, and the confirmation marks each value you chose. Scheduled runs cannot ask questions.",
350
+ inputSchema,
351
+ category: 'custom',
352
+ permissions: [],
353
+ readOnly: (input) => input.action === 'list',
354
+ // `delete` removes a job only after the operator confirms it on the
355
+ // host's own screen; a destructive flag would put a second review,
356
+ // over the raw arguments, in front of that confirmation.
357
+ destructive: false,
358
+ concurrencySafe: false,
359
+ presentCall: presentScheduleCall,
360
+ presentResult: presentScheduleResult,
361
+ // A person, not a tool, answers `create`/`resume`/`delete`; see
362
+ // `OPERATOR_CONFIRM_TIMEOUT_MS`.
363
+ timeoutMs: OPERATOR_CONFIRM_TIMEOUT_MS,
364
+ async execute(input, context: ToolContext) {
365
+ switch (input.action) {
366
+ case 'create':
367
+ return create(host, input, context.abortSignal)
368
+ case 'list':
369
+ return list(host, input)
370
+ default:
371
+ return lifecycle(host, input, input.action, context.abortSignal)
372
+ }
373
+ },
374
+ }),
375
+ ]
376
+ }