@namzu/sdk 45.1.0 → 46.0.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 (118) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/authorization/gate.d.ts +5 -2
  3. package/dist/authorization/gate.d.ts.map +1 -1
  4. package/dist/authorization/gate.js +25 -4
  5. package/dist/authorization/gate.js.map +1 -1
  6. package/dist/authorization/rules.d.ts.map +1 -1
  7. package/dist/authorization/rules.js +19 -0
  8. package/dist/authorization/rules.js.map +1 -1
  9. package/dist/authorization/shell-lexer.d.ts +24 -0
  10. package/dist/authorization/shell-lexer.d.ts.map +1 -1
  11. package/dist/authorization/shell-lexer.js +69 -51
  12. package/dist/authorization/shell-lexer.js.map +1 -1
  13. package/dist/pricing/catalogue.generated.d.ts.map +1 -1
  14. package/dist/pricing/catalogue.generated.js +28 -4
  15. package/dist/pricing/catalogue.generated.js.map +1 -1
  16. package/dist/public-runtime.d.ts +3 -3
  17. package/dist/public-runtime.d.ts.map +1 -1
  18. package/dist/public-runtime.js +4 -2
  19. package/dist/public-runtime.js.map +1 -1
  20. package/dist/public-tools.d.ts +3 -2
  21. package/dist/public-tools.d.ts.map +1 -1
  22. package/dist/public-tools.js +5 -2
  23. package/dist/public-tools.js.map +1 -1
  24. package/dist/public-types.d.ts +3 -3
  25. package/dist/public-types.d.ts.map +1 -1
  26. package/dist/registry/tool/callable.d.ts +22 -0
  27. package/dist/registry/tool/callable.d.ts.map +1 -0
  28. package/dist/registry/tool/callable.js +29 -0
  29. package/dist/registry/tool/callable.js.map +1 -0
  30. package/dist/registry/tool/execute.d.ts.map +1 -1
  31. package/dist/registry/tool/execute.js +8 -1
  32. package/dist/registry/tool/execute.js.map +1 -1
  33. package/dist/runtime/query/executor/tool-call-admission.d.ts +37 -11
  34. package/dist/runtime/query/executor/tool-call-admission.d.ts.map +1 -1
  35. package/dist/runtime/query/executor/tool-call-admission.js +38 -12
  36. package/dist/runtime/query/executor/tool-call-admission.js.map +1 -1
  37. package/dist/runtime/query/executor.d.ts +4 -2
  38. package/dist/runtime/query/executor.d.ts.map +1 -1
  39. package/dist/runtime/query/executor.js +18 -5
  40. package/dist/runtime/query/executor.js.map +1 -1
  41. package/dist/runtime/query/review-policy.d.ts +43 -0
  42. package/dist/runtime/query/review-policy.d.ts.map +1 -1
  43. package/dist/runtime/query/review-policy.js +59 -16
  44. package/dist/runtime/query/review-policy.js.map +1 -1
  45. package/dist/skills/registry.d.ts +6 -0
  46. package/dist/skills/registry.d.ts.map +1 -1
  47. package/dist/skills/registry.js +1 -0
  48. package/dist/skills/registry.js.map +1 -1
  49. package/dist/tools/builtins/computer-use-coordinates.d.ts +65 -0
  50. package/dist/tools/builtins/computer-use-coordinates.d.ts.map +1 -0
  51. package/dist/tools/builtins/computer-use-coordinates.js +123 -0
  52. package/dist/tools/builtins/computer-use-coordinates.js.map +1 -0
  53. package/dist/tools/builtins/computer-use-image.d.ts +77 -0
  54. package/dist/tools/builtins/computer-use-image.d.ts.map +1 -0
  55. package/dist/tools/builtins/computer-use-image.js +223 -0
  56. package/dist/tools/builtins/computer-use-image.js.map +1 -0
  57. package/dist/tools/builtins/computer-use.d.ts +519 -14
  58. package/dist/tools/builtins/computer-use.d.ts.map +1 -1
  59. package/dist/tools/builtins/computer-use.js +1188 -183
  60. package/dist/tools/builtins/computer-use.js.map +1 -1
  61. package/dist/tools/builtins/index.d.ts +2 -1
  62. package/dist/tools/builtins/index.d.ts.map +1 -1
  63. package/dist/tools/builtins/index.js +1 -1
  64. package/dist/tools/builtins/index.js.map +1 -1
  65. package/dist/tools/builtins/skill.d.ts +74 -0
  66. package/dist/tools/builtins/skill.d.ts.map +1 -1
  67. package/dist/tools/builtins/skill.js +214 -174
  68. package/dist/tools/builtins/skill.js.map +1 -1
  69. package/dist/tools/defineTool.d.ts +2 -0
  70. package/dist/tools/defineTool.d.ts.map +1 -1
  71. package/dist/tools/defineTool.js +7 -0
  72. package/dist/tools/defineTool.js.map +1 -1
  73. package/dist/tools/schedules/present.d.ts.map +1 -1
  74. package/dist/tools/schedules/present.js +2 -0
  75. package/dist/tools/schedules/present.js.map +1 -1
  76. package/dist/tools/schedules/schedule-tool.d.ts +6 -5
  77. package/dist/tools/schedules/schedule-tool.d.ts.map +1 -1
  78. package/dist/tools/schedules/schedule-tool.js +121 -30
  79. package/dist/tools/schedules/schedule-tool.js.map +1 -1
  80. package/dist/tools/schedules/types.d.ts +69 -1
  81. package/dist/tools/schedules/types.d.ts.map +1 -1
  82. package/dist/types/authorization/index.d.ts +21 -0
  83. package/dist/types/authorization/index.d.ts.map +1 -1
  84. package/dist/types/authorization/index.js +5 -0
  85. package/dist/types/authorization/index.js.map +1 -1
  86. package/dist/types/computer-use/index.d.ts +176 -0
  87. package/dist/types/computer-use/index.d.ts.map +1 -1
  88. package/dist/types/computer-use/index.js.map +1 -1
  89. package/dist/types/tool/index.d.ts +19 -0
  90. package/dist/types/tool/index.d.ts.map +1 -1
  91. package/dist/types/tool/index.js.map +1 -1
  92. package/package.json +3 -1
  93. package/src/authorization/gate.ts +25 -4
  94. package/src/authorization/rules.ts +18 -0
  95. package/src/authorization/shell-lexer.ts +73 -45
  96. package/src/pricing/catalogue.generated.ts +28 -4
  97. package/src/pricing/rates.source.json +27 -6
  98. package/src/public-runtime.ts +16 -2
  99. package/src/public-tools.ts +19 -1
  100. package/src/public-types.ts +5 -0
  101. package/src/registry/tool/callable.ts +37 -0
  102. package/src/registry/tool/execute.ts +8 -1
  103. package/src/runtime/query/executor/tool-call-admission.ts +60 -16
  104. package/src/runtime/query/executor.ts +19 -4
  105. package/src/runtime/query/review-policy.ts +103 -15
  106. package/src/skills/registry.ts +8 -0
  107. package/src/tools/builtins/computer-use-coordinates.ts +144 -0
  108. package/src/tools/builtins/computer-use-image.ts +278 -0
  109. package/src/tools/builtins/computer-use.ts +1485 -191
  110. package/src/tools/builtins/index.ts +7 -1
  111. package/src/tools/builtins/skill.ts +304 -174
  112. package/src/tools/defineTool.ts +10 -0
  113. package/src/tools/schedules/present.ts +2 -0
  114. package/src/tools/schedules/schedule-tool.ts +139 -33
  115. package/src/tools/schedules/types.ts +69 -1
  116. package/src/types/authorization/index.ts +21 -0
  117. package/src/types/computer-use/index.ts +202 -0
  118. package/src/types/tool/index.ts +19 -0
@@ -7,7 +7,13 @@ export { LSP_TOOL_NAME, LspTool, getCodeNavigationTools } from './lsp.js'
7
7
  export { GrepTool } from './grep.js'
8
8
  export { JobTool } from './job.js'
9
9
  export { WaitForJobTool } from './wait-for-job.js'
10
- export { SKILL_TOOL_NAME, SkillTool, parseAllowedTools } from './skill.js'
10
+ export { SKILL_TOOL_NAME, SkillTool, createSkillTool, parseAllowedTools } from './skill.js'
11
+ export type {
12
+ SkillDirectoryContext,
13
+ SkillDirectoryRequest,
14
+ SkillDirectoryResolver,
15
+ SkillToolOptions,
16
+ } from './skill.js'
11
17
  export {
12
18
  WEB_FETCH_TOOL_NAME,
13
19
  WEB_SEARCH_TOOL_NAME,
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import { z } from 'zod'
3
3
 
4
4
  import { parseAllowedTools } from '../../authorization/skill-grant.js'
5
+ import type { Sandbox } from '../../types/sandbox/index.js'
5
6
  import { isInvocableBy, skillInvocation } from '../../types/skills/index.js'
6
7
  import { defineTool } from '../defineTool.js'
7
8
 
@@ -22,8 +23,63 @@ export { parseAllowedTools }
22
23
  * else, from the next batch — which inverted what skill authors mean by it
23
24
  * and left a model that loaded `allowed-tools: Read Grep` without `bash`.
24
25
  * See `authorization/skill-grant.ts` for what a grant can and cannot do.
26
+ *
27
+ * A skill's body often names files beside it (`scripts/`, `references/`,
28
+ * `assets/`), and the load result carried no directory to open them from. The
29
+ * listing reported the registry's `location`, which is where the HOST reads
30
+ * the skill — a path the model cannot open once its tools run in a sandbox or
31
+ * a remote workspace, so it hard-coded what the skill said to read, or went
32
+ * searching the filesystem. Only the host knows what its tools can reach, so
33
+ * the host says it: {@link SkillToolOptions.resolveModelDirectory}.
25
34
  */
26
35
 
36
+ /** One skill, as the `skill` tool asks a host about it. */
37
+ export interface SkillDirectoryRequest {
38
+ /** The name the registry accepts, namespaced for a plugin skill (`plugin__skill`). */
39
+ readonly name: string
40
+ /**
41
+ * Where the host loads the skill from (`Skill.dirPath`, or the catalog
42
+ * entry's `directory`); undefined when the registry does not say.
43
+ */
44
+ readonly directory: string | undefined
45
+ }
46
+
47
+ /** What the call knows about where the model's tools run. */
48
+ export interface SkillDirectoryContext {
49
+ /** The turn's sandbox, when its tools run in one. Absent on the host. */
50
+ readonly sandbox?: Sandbox
51
+ }
52
+
53
+ /**
54
+ * The directory the model's tools can open for a skill, or undefined when
55
+ * they cannot reach it.
56
+ *
57
+ * Asked per call, with the turn's sandbox, because the answer is a property
58
+ * of where the tools run and not of the skill: the same skill is at its own
59
+ * path on the host, somewhere else inside a container, and absent from a
60
+ * sandbox that does not mount it.
61
+ */
62
+ export type SkillDirectoryResolver = (
63
+ skill: SkillDirectoryRequest,
64
+ context: SkillDirectoryContext,
65
+ ) => string | undefined | Promise<string | undefined>
66
+
67
+ export interface SkillToolOptions {
68
+ /**
69
+ * Say which directory the model can open for each skill.
70
+ *
71
+ * Absent, the tool behaves as it always has: the listing carries the
72
+ * registry's `location` and a load names no directory. Present, a load
73
+ * opens with the directory it returns, or with a line saying the skill's
74
+ * files are not reachable when it returns undefined; the listing carries
75
+ * that `directory` and never the registry's `location`, which is the
76
+ * host's path; and `${CLAUDE_SKILL_DIR}` in `allowed-tools` expands to it,
77
+ * since a command line the model writes names the path the model was
78
+ * given. The skill is still LOADED from the registry's own path.
79
+ */
80
+ readonly resolveModelDirectory?: SkillDirectoryResolver
81
+ }
82
+
27
83
  const inputSchema = z.object({
28
84
  name: z
29
85
  .string()
@@ -53,6 +109,8 @@ interface SkillSnapshot {
53
109
  readonly allowedTools: readonly string[] | undefined
54
110
  /** Bound into the cursor because `${CLAUDE_SKILL_DIR}` in a grant expands to it. */
55
111
  readonly skillDirectory: string | undefined
112
+ /** What the first page opens with: where the skill's files are, or that they are out of reach. */
113
+ readonly header: string
56
114
  readonly invocation: ReturnType<typeof skillInvocation>
57
115
  }
58
116
 
@@ -64,7 +122,10 @@ interface SkillPage {
64
122
  interface ListedSkill {
65
123
  readonly name: string
66
124
  readonly description: string
67
- readonly location: string
125
+ /** The registry's path to the SKILL.md; only when no host resolver is configured. */
126
+ readonly location?: string
127
+ /** The directory the model can open, from the host's resolver. */
128
+ readonly directory?: string
68
129
  readonly allowedTools?: string
69
130
  }
70
131
 
@@ -85,6 +146,7 @@ function snapshotDigest(snapshot: SkillSnapshot): string {
85
146
  ...(snapshot.skillDirectory === undefined
86
147
  ? {}
87
148
  : { skillDirectory: snapshot.skillDirectory }),
149
+ ...(snapshot.header === '' ? {} : { header: snapshot.header }),
88
150
  invocation: snapshot.invocation,
89
151
  }),
90
152
  )
@@ -218,7 +280,9 @@ function pageSkillBody(input: {
218
280
  readonly maxChars: number | undefined
219
281
  }): SkillPage | undefined {
220
282
  const { snapshot, digest, start, notice, maxChars } = input
221
- const remaining = `${snapshot.body.slice(start)}${notice}`
283
+ // The first page only: a continuation is read after it, in the same history.
284
+ const header = start === 0 ? snapshot.header : ''
285
+ const remaining = `${header}${snapshot.body.slice(start)}${notice}`
222
286
  if (maxChars === undefined || maxChars <= 0 || remaining.length <= maxChars) {
223
287
  return { output: remaining }
224
288
  }
@@ -229,7 +293,7 @@ function pageSkillBody(input: {
229
293
  let end = boundaryAtOrBefore(snapshot.body, Math.min(snapshot.body.length - 1, start + maxChars))
230
294
  while (end > start) {
231
295
  const nextCursor = cursorFor(end, digest)
232
- const output = `${snapshot.body.slice(start, end)}${continuationNotice(
296
+ const output = `${header}${snapshot.body.slice(start, end)}${continuationNotice(
233
297
  snapshot.name,
234
298
  nextCursor,
235
299
  )}${notice}`
@@ -242,216 +306,282 @@ function pageSkillBody(input: {
242
306
 
243
307
  export const SKILL_TOOL_NAME = 'skill'
244
308
 
245
- export const SkillTool = defineTool({
246
- name: SKILL_TOOL_NAME,
247
- description:
248
- 'Lists model-invocable skills when called without a name, or loads one skill by its exact listed name. Long lists and bodies return an opaque continuation cursor; keep calling in the same mode with that cursor until no continuation remains. The manifest carries only names and descriptions.',
249
- inputSchema,
250
- category: 'analysis',
251
- permissions: [],
252
- // Reads instructions and changes nothing on disk. What it does change is
253
- // the turn's approvals, through `grantSkillTools`, and only ever towards
254
- // fewer prompts for calls the operator's policy already leaves to review.
255
- readOnly: true,
256
- destructive: false,
257
- concurrencySafe: true,
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
-
277
- async execute(input: SkillInput, context) {
278
- if (!context.skills) {
309
+ /**
310
+ * Build the `skill` tool, with what the host knows about where its model's
311
+ * tools run. {@link SkillTool} is this with no options.
312
+ */
313
+ export function createSkillTool(options: SkillToolOptions = {}) {
314
+ const resolveModelDirectory = options.resolveModelDirectory
315
+ return defineTool({
316
+ name: SKILL_TOOL_NAME,
317
+ description:
318
+ 'Lists model-invocable skills when called without a name, or loads one skill by its exact listed name. Long lists and bodies return an opaque continuation cursor; keep calling in the same mode with that cursor until no continuation remains. The manifest carries only names and descriptions.',
319
+ inputSchema,
320
+ category: 'analysis',
321
+ permissions: [],
322
+ // Reads instructions and changes nothing on disk. What it does change is
323
+ // the turn's approvals, through `grantSkillTools`, and only ever towards
324
+ // fewer prompts for calls the operator's policy already leaves to review.
325
+ readOnly: true,
326
+ destructive: false,
327
+ concurrencySafe: true,
328
+
329
+ // The body is instructions for the model, often a hundred lines; the
330
+ // person needs the row that says which skill was read, not the text.
331
+ presentCall(input: SkillInput) {
332
+ const name = typeof input?.name === 'string' ? input.name : undefined
279
333
  return {
280
- success: false,
281
- output: '',
282
- error:
283
- 'This turn has no skills registry, so there is nothing to load. Proceed without the skill.',
334
+ kind: 'generic',
335
+ presentation: 'activity',
336
+ label:
337
+ name === undefined
338
+ ? input?.cursor === undefined
339
+ ? 'List skills'
340
+ : 'List more skills'
341
+ : `Read skill ${name}${input.cursor === undefined ? '' : ' (continued)'}`,
284
342
  }
285
- }
343
+ },
344
+ presentResult: (_input: SkillInput, result) =>
345
+ result.success ? { kind: 'generic', label: 'read', visibility: 'hidden' } : undefined,
286
346
 
287
- if (input.name === undefined) {
288
- if (!context.skills.catalog) {
347
+ async execute(input: SkillInput, context) {
348
+ if (!context.skills) {
289
349
  return {
290
350
  success: false,
291
351
  output: '',
292
352
  error:
293
- 'This skills registry cannot enumerate model-safe metadata. Use a skill name from the available-skills manifest.',
353
+ 'This turn has no skills registry, so there is nothing to load. Proceed without the skill.',
294
354
  }
295
355
  }
296
- const skills = (await context.skills.catalog())
297
- .filter(
298
- (entry) =>
299
- entry.invocation === undefined ||
300
- entry.invocation === 'model' ||
301
- entry.invocation === 'both',
302
- )
303
- .map(
304
- (entry): ListedSkill => ({
305
- name: entry.registeredName,
306
- description: entry.description,
307
- location: entry.location,
308
- ...(entry.allowedTools === undefined ? {} : { allowedTools: entry.allowedTools }),
309
- }),
356
+
357
+ if (input.name === undefined) {
358
+ if (!context.skills.catalog) {
359
+ return {
360
+ success: false,
361
+ output: '',
362
+ error:
363
+ 'This skills registry cannot enumerate model-safe metadata. Use a skill name from the available-skills manifest.',
364
+ }
365
+ }
366
+ const directoryContext = directoryContextOf(context.sandbox)
367
+ const skills = await Promise.all(
368
+ (await context.skills.catalog())
369
+ .filter(
370
+ (entry) =>
371
+ entry.invocation === undefined ||
372
+ entry.invocation === 'model' ||
373
+ entry.invocation === 'both',
374
+ )
375
+ .map(async (entry): Promise<ListedSkill> => {
376
+ // With a resolver the registry's `location` is left out, not
377
+ // shown beside the directory: it is the host's path, the one
378
+ // a sandboxed model cannot open.
379
+ let where: Pick<ListedSkill, 'location' | 'directory'>
380
+ if (resolveModelDirectory) {
381
+ const directory = nonEmpty(
382
+ await resolveModelDirectory(
383
+ { name: entry.registeredName, directory: entry.directory },
384
+ directoryContext,
385
+ ),
386
+ )
387
+ where = directory === undefined ? {} : { directory }
388
+ } else {
389
+ where = { location: entry.location }
390
+ }
391
+ return {
392
+ name: entry.registeredName,
393
+ description: entry.description,
394
+ ...where,
395
+ ...(entry.allowedTools === undefined ? {} : { allowedTools: entry.allowedTools }),
396
+ }
397
+ }),
310
398
  )
311
- const maxChars = activeOutputCap(context.maxToolOutputChars)
312
- const digest = listDigest(skills, maxChars)
399
+ const maxChars = activeOutputCap(context.maxToolOutputChars)
400
+ const digest = listDigest(skills, maxChars)
401
+ let start = 0
402
+ let warningAlreadyShown = false
403
+ if (input.cursor !== undefined) {
404
+ const parsed = parseListCursor(input.cursor)
405
+ if (!parsed || parsed.digest !== digest || parsed.offset >= skills.length) {
406
+ return {
407
+ success: false,
408
+ output: '',
409
+ error:
410
+ 'The skill-list continuation cursor is stale or invalid. Call skill again without a cursor to read the current catalog.',
411
+ }
412
+ }
413
+ start = parsed.offset
414
+ warningAlreadyShown = parsed.warned
415
+ }
416
+
417
+ const page = pageSkillCatalog({
418
+ skills,
419
+ digest,
420
+ start,
421
+ warningAlreadyShown,
422
+ maxChars,
423
+ })
424
+ if (!page) {
425
+ return {
426
+ success: false,
427
+ output: '',
428
+ error:
429
+ 'The model-visible tool-output budget is too small to list skill metadata safely. Increase maxToolOutputChars and retry.',
430
+ }
431
+ }
432
+
433
+ return {
434
+ success: true,
435
+ output: serializeListPage(page),
436
+ data: {
437
+ kind: 'list',
438
+ count: page.skills.length,
439
+ ...(page.nextCursor === null ? {} : { nextCursor: page.nextCursor }),
440
+ },
441
+ }
442
+ }
443
+
444
+ // The registry answers with a load RESULT, not a skill — the shape
445
+ // mirrors the implementation rather than an adapter, so there is
446
+ // nothing between them to drift.
447
+ const loaded = await context.skills.load(input.name)
448
+ if (!loaded) {
449
+ // Named, with what IS available. A bare "not found" sends the model
450
+ // guessing at spellings, and the manifest it is guessing from is
451
+ // right there in its own prompt.
452
+ const available = context.skills.names()
453
+ return {
454
+ success: false,
455
+ output: '',
456
+ error: `No skill named "${input.name}". Available: ${available.length > 0 ? available.join(', ') : '(none)'}`,
457
+ }
458
+ }
459
+
460
+ const skill = loaded.skill
461
+ const invocation = skillInvocation(skill)
462
+ if (!isInvocableBy(skill, 'model')) {
463
+ // Reachable even though the manifest omits it: the model can name
464
+ // anything, and a check that only filtered the listing would be a
465
+ // menu restriction rather than a kitchen one — the exact defect
466
+ // `allowedTools` had before it was enforced at dispatch.
467
+ return {
468
+ success: false,
469
+ output: '',
470
+ error: `The skill "${input.name}" is ${skillInvocation(skill)}-invocable; it is not for you to run.`,
471
+ }
472
+ }
473
+
474
+ const allowed = parseAllowedTools(skill.metadata.allowedTools)
475
+ // Asked on every call, first page or continuation: the digest binds
476
+ // the answer, so a mount that changed between pages is a stale cursor
477
+ // rather than a second half written for a different directory.
478
+ const modelDirectory = resolveModelDirectory
479
+ ? nonEmpty(
480
+ await resolveModelDirectory(
481
+ { name: input.name, directory: skill.dirPath },
482
+ directoryContextOf(context.sandbox),
483
+ ),
484
+ )
485
+ : undefined
486
+ const snapshot: SkillSnapshot = {
487
+ name: input.name,
488
+ body: skill.body ?? '(this skill has no body)',
489
+ allowedTools: allowed,
490
+ // The model's path when the host gave one: `${CLAUDE_SKILL_DIR}` in a
491
+ // pattern is matched against a command line the model writes, and it
492
+ // writes the path it was told.
493
+ skillDirectory: resolveModelDirectory ? modelDirectory : skill.dirPath,
494
+ header: resolveModelDirectory ? directoryHeader(modelDirectory) : '',
495
+ invocation,
496
+ }
497
+ const digest = snapshotDigest(snapshot)
313
498
  let start = 0
314
- let warningAlreadyShown = false
315
499
  if (input.cursor !== undefined) {
316
- const parsed = parseListCursor(input.cursor)
317
- if (!parsed || parsed.digest !== digest || parsed.offset >= skills.length) {
500
+ const parsed = parseCursor(input.cursor)
501
+ if (
502
+ !parsed ||
503
+ parsed.digest !== digest ||
504
+ parsed.offset >= snapshot.body.length ||
505
+ !isCodePointBoundary(snapshot.body, parsed.offset)
506
+ ) {
318
507
  return {
319
508
  success: false,
320
509
  output: '',
321
- error:
322
- 'The skill-list continuation cursor is stale or invalid. Call skill again without a cursor to read the current catalog.',
510
+ error: `The continuation cursor for "${input.name}" is stale or invalid. Call skill again without a cursor to read the current instructions.`,
323
511
  }
324
512
  }
325
513
  start = parsed.offset
326
- warningAlreadyShown = parsed.warned
327
514
  }
328
515
 
329
- const page = pageSkillCatalog({
330
- skills,
516
+ // Compiled before paging so the notice can say what the grant is, and
517
+ // committed only after paging succeeded: a load that fails here gave
518
+ // the model no instructions, so it must not have approved anything.
519
+ // Idempotent: a continuation call grants the same entries again, and
520
+ // the turn's set keeps one copy.
521
+ let grant: ReturnType<NonNullable<typeof context.grantSkillTools>> | undefined
522
+ if (allowed !== undefined && allowed.length > 0 && context.grantSkillTools) {
523
+ grant = context.grantSkillTools({
524
+ skill: skill.metadata.name,
525
+ allowedTools: allowed,
526
+ ...(snapshot.skillDirectory ? { skillDirectory: snapshot.skillDirectory } : {}),
527
+ })
528
+ }
529
+ const notice = grantNotice(allowed, grant, context.grantSkillTools !== undefined)
530
+ const page = pageSkillBody({
531
+ snapshot,
331
532
  digest,
332
533
  start,
333
- warningAlreadyShown,
334
- maxChars,
534
+ notice,
535
+ maxChars: context.maxToolOutputChars,
335
536
  })
336
537
  if (!page) {
337
538
  return {
338
539
  success: false,
339
540
  output: '',
340
- error:
341
- 'The model-visible tool-output budget is too small to list skill metadata safely. Increase maxToolOutputChars and retry.',
541
+ error: `The model-visible tool-output budget is too small to read "${input.name}" safely. Increase maxToolOutputChars and retry.`,
342
542
  }
343
543
  }
544
+ grant?.commit()
344
545
 
345
546
  return {
346
547
  success: true,
347
- output: serializeListPage(page),
548
+ output: page.output,
348
549
  data: {
349
- kind: 'list',
350
- count: page.skills.length,
351
- ...(page.nextCursor === null ? {} : { nextCursor: page.nextCursor }),
550
+ skill: skill.metadata.name,
551
+ ...(modelDirectory === undefined ? {} : { directory: modelDirectory }),
552
+ ...(allowed === undefined ? {} : { allowedTools: allowed }),
553
+ ...(grant ? { granted: grant.granted, ignored: grant.ignored } : {}),
554
+ ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
352
555
  },
353
556
  }
354
- }
557
+ },
558
+ })
559
+ }
355
560
 
356
- // The registry answers with a load RESULT, not a skill — the shape
357
- // mirrors the implementation rather than an adapter, so there is
358
- // nothing between them to drift.
359
- const loaded = await context.skills.load(input.name)
360
- if (!loaded) {
361
- // Named, with what IS available. A bare "not found" sends the model
362
- // guessing at spellings, and the manifest it is guessing from is
363
- // right there in its own prompt.
364
- const available = context.skills.names()
365
- return {
366
- success: false,
367
- output: '',
368
- error: `No skill named "${input.name}". Available: ${available.length > 0 ? available.join(', ') : '(none)'}`,
369
- }
370
- }
561
+ /** The `skill` tool with no host options: the listing carries the registry's `location`. */
562
+ export const SkillTool = createSkillTool()
371
563
 
372
- const skill = loaded.skill
373
- const invocation = skillInvocation(skill)
374
- if (!isInvocableBy(skill, 'model')) {
375
- // Reachable even though the manifest omits it: the model can name
376
- // anything, and a check that only filtered the listing would be a
377
- // menu restriction rather than a kitchen one — the exact defect
378
- // `allowedTools` had before it was enforced at dispatch.
379
- return {
380
- success: false,
381
- output: '',
382
- error: `The skill "${input.name}" is ${skillInvocation(skill)}-invocable; it is not for you to run.`,
383
- }
384
- }
564
+ function directoryContextOf(sandbox: Sandbox | undefined): SkillDirectoryContext {
565
+ return sandbox ? { sandbox } : {}
566
+ }
385
567
 
386
- const allowed = parseAllowedTools(skill.metadata.allowedTools)
387
- const snapshot: SkillSnapshot = {
388
- name: input.name,
389
- body: skill.body ?? '(this skill has no body)',
390
- allowedTools: allowed,
391
- skillDirectory: skill.dirPath,
392
- invocation,
393
- }
394
- const digest = snapshotDigest(snapshot)
395
- let start = 0
396
- if (input.cursor !== undefined) {
397
- const parsed = parseCursor(input.cursor)
398
- if (
399
- !parsed ||
400
- parsed.digest !== digest ||
401
- parsed.offset >= snapshot.body.length ||
402
- !isCodePointBoundary(snapshot.body, parsed.offset)
403
- ) {
404
- return {
405
- success: false,
406
- output: '',
407
- error: `The continuation cursor for "${input.name}" is stale or invalid. Call skill again without a cursor to read the current instructions.`,
408
- }
409
- }
410
- start = parsed.offset
411
- }
568
+ /** An empty string names no directory; treated as the resolver saying none. */
569
+ function nonEmpty(directory: string | undefined): string | undefined {
570
+ return directory === undefined || directory === '' ? undefined : directory
571
+ }
412
572
 
413
- // Compiled before paging so the notice can say what the grant is, and
414
- // committed only after paging succeeded: a load that fails here gave
415
- // the model no instructions, so it must not have approved anything.
416
- // Idempotent: a continuation call grants the same entries again, and
417
- // the turn's set keeps one copy.
418
- let grant: ReturnType<NonNullable<typeof context.grantSkillTools>> | undefined
419
- if (allowed !== undefined && allowed.length > 0 && context.grantSkillTools) {
420
- grant = context.grantSkillTools({
421
- skill: skill.metadata.name,
422
- allowedTools: allowed,
423
- ...(snapshot.skillDirectory ? { skillDirectory: snapshot.skillDirectory } : {}),
424
- })
425
- }
426
- const notice = grantNotice(allowed, grant, context.grantSkillTools !== undefined)
427
- const page = pageSkillBody({
428
- snapshot,
429
- digest,
430
- start,
431
- notice,
432
- maxChars: context.maxToolOutputChars,
433
- })
434
- if (!page) {
435
- return {
436
- success: false,
437
- output: '',
438
- error: `The model-visible tool-output budget is too small to read "${input.name}" safely. Increase maxToolOutputChars and retry.`,
439
- }
440
- }
441
- grant?.commit()
442
-
443
- return {
444
- success: true,
445
- output: page.output,
446
- data: {
447
- skill: skill.metadata.name,
448
- ...(allowed === undefined ? {} : { allowedTools: allowed }),
449
- ...(grant ? { granted: grant.granted, ignored: grant.ignored } : {}),
450
- ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
451
- },
452
- }
453
- },
454
- })
573
+ /**
574
+ * The line a load opens with once a host has said where the skill's files are.
575
+ *
576
+ * The unreachable case is said out loud rather than left blank. A model given
577
+ * no directory for a skill that says "run scripts/render.sh" goes looking for
578
+ * it, and on a sandboxed turn that search can only fail.
579
+ */
580
+ function directoryHeader(directory: string | undefined): string {
581
+ return directory === undefined
582
+ ? "[This skill's directory is not reachable from your tools in this session, so a file these instructions name by a relative path (scripts/, references/, assets/) cannot be opened here. Do not search the filesystem for it; if the task needs one, say so.]\n\n"
583
+ : `[Skill directory: ${directory}. Relative paths in these instructions, such as scripts/, references/ or assets/, are inside it.]\n\n`
584
+ }
455
585
 
456
586
  /**
457
587
  * What the model is told about `allowed-tools`.
@@ -21,6 +21,8 @@ export interface DefineToolOptions<S extends z.ZodType> {
21
21
  readOnly: boolean | ((input: z.infer<S>) => boolean)
22
22
  destructive: boolean | ((input: z.infer<S>) => boolean)
23
23
  concurrencySafe: boolean
24
+ /** Whether this exact call sends the screen to the provider; see {@link ToolDefinition.capturesScreen}. */
25
+ capturesScreen?: boolean | ((input: z.infer<S>) => boolean)
24
26
  /** Batch ordering boundary; see {@link ToolDefinition.executionBarrier}. */
25
27
  executionBarrier?: boolean
26
28
  tier?: string
@@ -139,6 +141,14 @@ export function defineTool<S extends z.ZodType>(
139
141
  ? options.destructive
140
142
  : constantDestructive(options.destructive as boolean),
141
143
  isConcurrencySafe: () => options.concurrencySafe,
144
+ ...(options.capturesScreen !== undefined
145
+ ? {
146
+ capturesScreen:
147
+ typeof options.capturesScreen === 'function'
148
+ ? options.capturesScreen
149
+ : () => options.capturesScreen as boolean,
150
+ }
151
+ : {}),
142
152
 
143
153
  async execute(input: TInput, context: ToolContext): Promise<ToolResult> {
144
154
  try {
@@ -33,6 +33,8 @@ export function presentScheduleCall(input: {
33
33
  }
34
34
  case 'list':
35
35
  return activity('List scheduled jobs')
36
+ case 'update':
37
+ return activity(`Change scheduled job · ${oneLine(input.job)}`)
36
38
  case 'pause':
37
39
  return activity(`Pause scheduled job · ${oneLine(input.job)}`)
38
40
  case 'resume':