pantheon-opencode 1.6.0-beta.1 → 1.6.0-beta.10

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 (120) hide show
  1. package/AGENTS.global.md +182 -0
  2. package/AGENTS.md +10 -891
  3. package/CHANGELOG.md +326 -0
  4. package/README.md +496 -32
  5. package/README.pt-BR.md +285 -20
  6. package/bin/pantheon-init.mjs +26 -3
  7. package/commands/pantheon-audit.md +7 -1
  8. package/commands/pantheon-consolidate.md +1 -1
  9. package/commands/pantheon-deepwork.md +14 -3
  10. package/commands/pantheon-focus.md +17 -0
  11. package/commands/pantheon-vision.md +61 -12
  12. package/package.json +21 -14
  13. package/plugin.json +2 -2
  14. package/pyproject.toml +8 -6
  15. package/scripts/build-agents-md.mjs +374 -56
  16. package/scripts/changelog-merge.mjs +161 -0
  17. package/scripts/{code_mode_server.py → code_mode.py} +3 -3
  18. package/scripts/cost.mjs +244 -41
  19. package/scripts/doctor.mjs +151 -1
  20. package/scripts/install/agents-md.mjs +4 -4
  21. package/scripts/install/config-migration.mjs +317 -4
  22. package/scripts/install/health-check.mjs +21 -19
  23. package/scripts/install/interactive.mjs +1 -1
  24. package/scripts/install/opencode-version.mjs +246 -4
  25. package/scripts/install/opencode.mjs +627 -179
  26. package/scripts/install/plugin.mjs +43 -9
  27. package/scripts/install/shared.mjs +120 -12
  28. package/scripts/install/vision-prompt.mjs +161 -0
  29. package/scripts/install-mcp.mjs +28 -9
  30. package/scripts/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  31. package/{src/mcp/mcp_resources_server.py → scripts/mcp_resources.py} +2 -2
  32. package/scripts/{memory_mcp_server.py → memory_mcp.py} +2 -2
  33. package/scripts/probe-context-rehydrate.mjs +8 -2
  34. package/scripts/probe-pantheon-cost.mjs +71 -15
  35. package/scripts/run-node-tests.mjs +189 -0
  36. package/scripts/sync-artifacts.mjs +26 -13
  37. package/scripts/sync-tui.mjs +24 -3
  38. package/scripts/test-opencode-v2-sandbox.sh +180 -26
  39. package/scripts/validate-routing.mjs +3 -3
  40. package/scripts/validate-sandbox-install.mjs +138 -0
  41. package/scripts/versioning.mjs +13 -1
  42. package/scripts/vision-status.mjs +682 -0
  43. package/skills-lock.json +8 -8
  44. package/src/agents/aphrodite.md +20 -37
  45. package/src/agents/apollo.md +7 -3
  46. package/src/agents/athena.md +63 -35
  47. package/src/agents/demeter.md +8 -28
  48. package/src/agents/gaia.md +1 -1
  49. package/src/agents/hephaestus.md +10 -21
  50. package/src/agents/hermes.md +15 -38
  51. package/src/agents/iris.md +1 -1
  52. package/src/agents/mnemosyne.md +18 -54
  53. package/src/agents/nyx.md +1 -1
  54. package/src/agents/prometheus.md +16 -20
  55. package/src/agents/talos.md +10 -2
  56. package/src/agents/themis.md +5 -5
  57. package/src/agents/zeus.md +99 -129
  58. package/src/instructions/agent-return-format.instructions.md +18 -3
  59. package/src/instructions/backend-standards.instructions.md +2 -0
  60. package/src/instructions/frontend-standards.instructions.md +3 -1
  61. package/src/instructions/memory-protocol.instructions.md +11 -110
  62. package/src/instructions/zeus-anti-stall.instructions.md +14 -61
  63. package/src/instructions/zeus-communication-rules.instructions.md +0 -1
  64. package/src/instructions/zeus-council-synthesis.instructions.md +32 -260
  65. package/src/instructions/zeus-memory-operations.instructions.md +26 -0
  66. package/src/instructions/zeus-timeout-retry.instructions.md +40 -87
  67. package/src/mcp/{code_mode_server.py → code_mode.py} +3 -3
  68. package/src/mcp/eval_store.py +2 -2
  69. package/src/mcp/{mcp_persistence_server.py → mcp_persistence.py} +2 -2
  70. package/{scripts/mcp_resources_server.py → src/mcp/mcp_resources.py} +2 -2
  71. package/src/mcp/{memory_mcp_server.py → memory_mcp.py} +2 -2
  72. package/src/mcp/{pantheon_vision_server.py → pantheon_vision.py} +286 -42
  73. package/src/mcp/requirements-mcp.txt +6 -4
  74. package/src/mcp/requirements-vision.txt +2 -2
  75. package/src/pantheon/cost-command.ts +399 -101
  76. package/src/pantheon/delegation-enforce.ts +10 -1
  77. package/src/pantheon/presets.d.mts +0 -8
  78. package/src/pantheon/presets.mjs +5 -32
  79. package/src/pantheon/v2-bounded-read.ts +640 -0
  80. package/src/pantheon/v2-delegation-enforce.ts +214 -0
  81. package/src/pantheon/v2-execute-after.ts +186 -0
  82. package/src/pantheon/v2-tools.ts +429 -0
  83. package/src/pantheon/v2-unsupported.d.mts +11 -0
  84. package/src/pantheon/v2-unsupported.mjs +90 -0
  85. package/src/pantheon/v2-vision.ts +622 -0
  86. package/src/pantheon/vision-local.ts +531 -0
  87. package/src/pantheon/vision.ts +1160 -115
  88. package/src/plugin-v2.ts +1145 -219
  89. package/src/plugin.ts +12 -9
  90. package/src/plugins/tui/dist/server.d.ts +1 -2
  91. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui-v2.d.ts +154 -0
  93. package/src/plugins/tui/dist/tui-v2.d.ts.map +1 -0
  94. package/src/plugins/tui/dist/tui-v2.js +659 -0
  95. package/src/plugins/tui/dist/tui-v2.js.map +1 -0
  96. package/src/plugins/tui/dist/tui.d.ts +85 -82
  97. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  98. package/src/plugins/tui/dist/tui.js +72 -59
  99. package/src/plugins/tui/index-v2.ts +6 -0
  100. package/src/plugins/tui/package-lock.json +5516 -1353
  101. package/src/plugins/tui/package.json +22 -12
  102. package/src/plugins/tui/src/index-v2.tsx +734 -0
  103. package/src/plugins/tui/src/index.tsx +106 -85
  104. package/src/plugins/tui/src/opencode-v2.d.ts +13 -0
  105. package/src/plugins/tui/tsconfig.build.json +5 -0
  106. package/src/plugins/tui/tsconfig.typecheck.json +5 -0
  107. package/src/plugins/tui/tsdown.config.ts +22 -2
  108. package/src/plugins/tui/tui-v2.ts +2 -0
  109. package/src/plugins/tui/tui.ts +3 -0
  110. package/src/routing.yml +11 -21
  111. package/src/skills/README.md +3 -3
  112. package/src/skills/agent-coordination/SKILL.md +7 -2
  113. package/src/skills/artifact-management/SKILL.md +12 -5
  114. package/src/skills/auto-continue/SKILL.md +39 -30
  115. package/src/skills/code-review-checklist/SKILL.md +5 -5
  116. package/src/skills/context-compression/SKILL.md +26 -879
  117. package/src/skills/session-goal/SKILL.md +14 -5
  118. package/src/skills/spec-driven-development/SKILL.md +3 -1
  119. package/src/skills/tdd-with-agents/SKILL.md +18 -10
  120. package/opencode.json +0 -58
@@ -0,0 +1,640 @@
1
+ /** Minimal, session-scoped bounded file reader for the OpenCode V2 surface. */
2
+
3
+ import { constants } from 'node:fs'
4
+ import { lstat, open, realpath } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import type { V2ToolDef } from './v2-tools.ts'
7
+
8
+ export const BOUNDED_READ_TOOL = 'pantheon_bounded_read'
9
+ export const BOUNDED_READ_MAX_CALLS = 2
10
+ export const BOUNDED_READ_MAX_FILES = 2
11
+ export const BOUNDED_READ_MAX_LINES = 400
12
+ export const BOUNDED_READ_MAX_BYTES = 32 * 1024
13
+ const BYTES_PER_CALL = BOUNDED_READ_MAX_BYTES / BOUNDED_READ_MAX_CALLS
14
+ const ORIGIN_TTL_MS = 60_000
15
+ const MAX_SESSIONS = 256
16
+ const READ_CHUNK_BYTES = 1
17
+ const DENIED_COMPONENTS = new Set([
18
+ '.aws',
19
+ '.config',
20
+ '.docker',
21
+ '.gnupg',
22
+ '.kube',
23
+ '.netrc',
24
+ '.npmrc',
25
+ '.pypirc',
26
+ '.ssh',
27
+ '.git',
28
+ 'auth',
29
+ 'authentication',
30
+ 'database',
31
+ 'db',
32
+ 'global',
33
+ 'migration',
34
+ 'migrations',
35
+ 'schema',
36
+ 'schemas',
37
+ 'data',
38
+ 'dataset',
39
+ 'datasets',
40
+ 'authorized_keys',
41
+ 'credentials',
42
+ 'id_ed25519',
43
+ 'id_rsa',
44
+ 'known_hosts',
45
+ 'secrets',
46
+ ])
47
+ const DENIED_EXTENSIONS = new Set([
48
+ '.cer',
49
+ '.crt',
50
+ '.der',
51
+ '.jks',
52
+ '.key',
53
+ '.p12',
54
+ '.p7b',
55
+ '.p7c',
56
+ '.pem',
57
+ '.pfx',
58
+ '.pub',
59
+ '.sqlite',
60
+ '.sqlite3',
61
+ '.db',
62
+ '.sql',
63
+ ])
64
+ const DENIED_MEDIA_EXTENSIONS = new Set([
65
+ '.avif',
66
+ '.bmp',
67
+ '.gif',
68
+ '.heic',
69
+ '.heif',
70
+ '.ico',
71
+ '.jpeg',
72
+ '.jpg',
73
+ '.png',
74
+ '.svg',
75
+ '.webp',
76
+ '.pdf',
77
+ '.mp3',
78
+ '.mp4',
79
+ '.wav',
80
+ '.zip',
81
+ '.gz',
82
+ '.tar',
83
+ '.7z',
84
+ '.a',
85
+ '.class',
86
+ '.dll',
87
+ '.docx',
88
+ '.dylib',
89
+ '.exe',
90
+ '.jar',
91
+ '.odt',
92
+ '.ods',
93
+ '.otf',
94
+ '.o',
95
+ '.pptx',
96
+ '.pyc',
97
+ '.rar',
98
+ '.so',
99
+ '.ttf',
100
+ '.wasm',
101
+ '.woff',
102
+ '.woff2',
103
+ '.xlsx',
104
+ '.xz',
105
+ ])
106
+
107
+ export interface BoundedReadIdentity {
108
+ sessionID: string
109
+ messageID: string
110
+ callID: string
111
+ agent: string
112
+ relativePath: string
113
+ }
114
+
115
+ interface StoredOrigin extends BoundedReadIdentity {
116
+ capturedAt: number
117
+ }
118
+
119
+ function isRecord(value: unknown): value is Record<string, unknown> {
120
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
121
+ }
122
+
123
+ function nonEmpty(value: unknown): string | undefined {
124
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
125
+ }
126
+
127
+ function originKey(sessionID: string, messageID: string, callID: string): string {
128
+ return JSON.stringify([sessionID, messageID, callID])
129
+ }
130
+
131
+ function budgetKey(sessionID: string, messageID: string): string {
132
+ return JSON.stringify([sessionID, messageID])
133
+ }
134
+
135
+ /** One-shot proof that this exact V2 root tool invocation reached execute(). */
136
+ export class V2BoundedReadOriginRegistry {
137
+ private readonly entries = new Map<string, StoredOrigin>()
138
+
139
+ capture(rawEvent: unknown, now = Date.now()): BoundedReadIdentity | undefined {
140
+ this.prune(now)
141
+ if (!isRecord(rawEvent) || rawEvent.tool !== BOUNDED_READ_TOOL || !isRecord(rawEvent.input)) {
142
+ return undefined
143
+ }
144
+ const identity = {
145
+ sessionID: nonEmpty(rawEvent.sessionID),
146
+ messageID: nonEmpty(rawEvent.messageID),
147
+ callID: nonEmpty(rawEvent.id),
148
+ agent: nonEmpty(rawEvent.agent),
149
+ relativePath: validateRelativePath(rawEvent.input.path) ? rawEvent.input.path : undefined,
150
+ }
151
+ if (
152
+ identity.sessionID === undefined ||
153
+ identity.messageID === undefined ||
154
+ identity.callID === undefined ||
155
+ identity.agent !== 'zeus' ||
156
+ identity.relativePath === undefined ||
157
+ this.entries.size >= MAX_SESSIONS
158
+ ) {
159
+ return undefined
160
+ }
161
+ const origin: BoundedReadIdentity = {
162
+ sessionID: identity.sessionID,
163
+ messageID: identity.messageID,
164
+ callID: identity.callID,
165
+ agent: identity.agent,
166
+ relativePath: identity.relativePath,
167
+ }
168
+ this.entries.set(originKey(origin.sessionID, origin.messageID, origin.callID), {
169
+ ...origin,
170
+ capturedAt: now,
171
+ })
172
+ return origin
173
+ }
174
+
175
+ consume(
176
+ identity: Omit<BoundedReadIdentity, 'relativePath'> & { relativePath: unknown },
177
+ now = Date.now(),
178
+ ): BoundedReadIdentity | undefined {
179
+ this.prune(now)
180
+ const key = originKey(identity.sessionID, identity.messageID, identity.callID)
181
+ const origin = this.entries.get(key)
182
+ this.entries.delete(key)
183
+ if (
184
+ origin === undefined ||
185
+ identity.agent !== origin.agent ||
186
+ identity.relativePath !== origin.relativePath
187
+ ) {
188
+ return undefined
189
+ }
190
+ const { capturedAt: _capturedAt, ...result } = origin
191
+ return result
192
+ }
193
+
194
+ forget(rawEvent: unknown): void {
195
+ if (!isRecord(rawEvent)) return
196
+ const sessionID = nonEmpty(rawEvent.sessionID)
197
+ const messageID = nonEmpty(rawEvent.messageID)
198
+ const callID = nonEmpty(rawEvent.id)
199
+ if (sessionID && messageID && callID) {
200
+ this.entries.delete(originKey(sessionID, messageID, callID))
201
+ }
202
+ }
203
+
204
+ clearSession(sessionID: string): void {
205
+ for (const [key, entry] of this.entries) {
206
+ if (entry.sessionID === sessionID) this.entries.delete(key)
207
+ }
208
+ }
209
+
210
+ private prune(now: number): void {
211
+ for (const [key, entry] of this.entries) {
212
+ if (now - entry.capturedAt > ORIGIN_TTL_MS) this.entries.delete(key)
213
+ }
214
+ }
215
+ }
216
+
217
+ interface Budget {
218
+ sessionID: string
219
+ messageID: string
220
+ root: string
221
+ files: Set<string>
222
+ calls: number
223
+ lines: number
224
+ bytes: number
225
+ sealed: boolean
226
+ }
227
+
228
+ export interface BoundedReadHostSession {
229
+ id?: unknown
230
+ parentID?: unknown
231
+ projectID?: unknown
232
+ }
233
+
234
+ export interface BoundedReadDependencies {
235
+ /** Project identity is taken from ctx.location once during plugin setup. */
236
+ rootDirectory: string
237
+ projectID: string
238
+ getSession: (sessionID: string) => Promise<unknown>
239
+ assertPermission: (request: Record<string, unknown>) => Promise<unknown>
240
+ scanContent: (input: {
241
+ content: string
242
+ agent: string
243
+ sessionID: string
244
+ messageID: string
245
+ callID: string
246
+ }) => Promise<'allow' | 'deny' | 'unavailable'>
247
+ }
248
+
249
+ interface ReadArgs {
250
+ path: string
251
+ offset: number
252
+ limit: number
253
+ }
254
+
255
+ function validateRelativePath(value: unknown): value is string {
256
+ if (typeof value !== 'string' || value.length === 0 || value.length > 240) return false
257
+ if (
258
+ value.startsWith('/') ||
259
+ value.startsWith('\\') ||
260
+ value.includes('\\') ||
261
+ value.includes('\0') ||
262
+ value.includes(':') ||
263
+ value.includes('*') ||
264
+ value.includes('?') ||
265
+ value.includes('[') ||
266
+ value.includes(']') ||
267
+ value.includes('{') ||
268
+ value.includes('}') ||
269
+ value.includes('%') ||
270
+ value.startsWith('~')
271
+ ) {
272
+ return false
273
+ }
274
+ const components = value.split('/')
275
+ if (components.some((component) => component === '' || component === '.' || component === '..')) {
276
+ return false
277
+ }
278
+ return components.every((component) =>
279
+ [...component].every((character) => {
280
+ const code = character.charCodeAt(0)
281
+ return code > 0x1f && code !== 0x7f
282
+ }),
283
+ )
284
+ }
285
+
286
+ function parseArgs(input: Record<string, unknown>): ReadArgs | undefined {
287
+ if (Object.keys(input).some((key) => !['path', 'offset', 'limit'].includes(key))) return undefined
288
+ const filePath = input.path
289
+ const offset = input.offset
290
+ const limit = input.limit
291
+ if (
292
+ !validateRelativePath(filePath) ||
293
+ !Number.isSafeInteger(offset) ||
294
+ (offset as number) < 1 ||
295
+ !Number.isSafeInteger(limit) ||
296
+ (limit as number) < 1 ||
297
+ (limit as number) > BOUNDED_READ_MAX_LINES
298
+ ) {
299
+ return undefined
300
+ }
301
+ return { path: filePath, offset: offset as number, limit: limit as number }
302
+ }
303
+
304
+ function isWithin(root: string, target: string): boolean {
305
+ const relative = path.relative(root, target)
306
+ return (
307
+ relative === '' ||
308
+ (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
309
+ )
310
+ }
311
+
312
+ function sensitivePath(relativePath: string): boolean {
313
+ const components = relativePath.split('/').map((component) => component.toLowerCase())
314
+ const leaf = components.at(-1) ?? ''
315
+ const extension = path.extname(leaf)
316
+ return (
317
+ components.some(
318
+ (component) =>
319
+ DENIED_COMPONENTS.has(component) ||
320
+ component.startsWith('.env') ||
321
+ /(^|[._-])(auth|authentication|data|dataset|datasets|database|db|schema|schemas|global|migration|migrations)([._-]|$)/.test(
322
+ component,
323
+ ),
324
+ ) ||
325
+ /(^|[._-])(secret|token|credential|credentials|password|private)([._-]|$)/.test(leaf) ||
326
+ DENIED_EXTENSIONS.has(extension) ||
327
+ DENIED_MEDIA_EXTENSIONS.has(extension)
328
+ )
329
+ }
330
+
331
+ function errorResult(code: string): { output: string } {
332
+ return { output: code }
333
+ }
334
+
335
+ function hasParent(session: BoundedReadHostSession): boolean {
336
+ return Object.hasOwn(session, 'parentID')
337
+ }
338
+
339
+ async function readLineWindow(
340
+ filePath: string,
341
+ offset: number,
342
+ limit: number,
343
+ byteCap: number,
344
+ ): Promise<{ content: string; linesRead: number; bytes: number }> {
345
+ const before = await lstat(filePath)
346
+ if (before.isSymbolicLink()) throw new Error('PATH_DENIED')
347
+ if (!before.isFile() || before.nlink > 1) throw new Error('PATH_DENIED')
348
+
349
+ let file: Awaited<ReturnType<typeof open>> | undefined
350
+ try {
351
+ file = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
352
+ const opened = await file.stat()
353
+ if (
354
+ !opened.isFile() ||
355
+ opened.dev !== before.dev ||
356
+ opened.ino !== before.ino ||
357
+ opened.nlink > 1
358
+ ) {
359
+ throw new Error('CHANGED')
360
+ }
361
+
362
+ const chunks: Buffer[] = []
363
+ const inspectedChunks: Buffer[] = []
364
+ const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES)
365
+ let position = 0
366
+ let currentLine = 1
367
+ let linesRead = 0
368
+ let selectedBytes = 0
369
+ let inspectedBytes = 0
370
+ let currentLineHasBytes = false
371
+ let intervalComplete = false
372
+ while (inspectedBytes < byteCap) {
373
+ const size = Math.min(buffer.length, byteCap - inspectedBytes)
374
+ const read = await file.read(buffer, 0, size, position)
375
+ if (read.bytesRead === 0) break
376
+ position += read.bytesRead
377
+ inspectedBytes += read.bytesRead
378
+ inspectedChunks.push(Buffer.from(buffer.subarray(0, read.bytesRead)))
379
+ let consumed = 0
380
+ let selectedStart = -1
381
+ for (; consumed < read.bytesRead; consumed++) {
382
+ const byte = buffer.readUInt8(consumed)
383
+ if (
384
+ byte === 0 ||
385
+ (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) ||
386
+ byte === 0x7f
387
+ ) {
388
+ throw new Error('PATH_DENIED')
389
+ }
390
+ const selected = currentLine >= offset && currentLine < offset + limit
391
+ if (selected) {
392
+ if (selectedStart === -1) selectedStart = consumed
393
+ currentLineHasBytes = true
394
+ }
395
+ if (byte === 0x0a) {
396
+ if (selected) linesRead += 1
397
+ currentLine += 1
398
+ currentLineHasBytes = false
399
+ if (currentLine >= offset + limit) {
400
+ intervalComplete = true
401
+ consumed += 1
402
+ break
403
+ }
404
+ } else if (!selected && currentLine >= offset && currentLine < offset + limit) {
405
+ currentLineHasBytes = true
406
+ }
407
+ }
408
+ if (selectedStart !== -1) {
409
+ const selectedEnd = consumed
410
+ const selectedChunk = Buffer.from(buffer.subarray(selectedStart, selectedEnd))
411
+ selectedBytes += selectedChunk.length
412
+ if (selectedBytes > byteCap) throw new Error('BUDGET_EXCEEDED')
413
+ chunks.push(selectedChunk)
414
+ }
415
+ if (intervalComplete) break
416
+ if (read.bytesRead < size) break
417
+ }
418
+
419
+ if (
420
+ !intervalComplete &&
421
+ currentLineHasBytes &&
422
+ currentLine >= offset &&
423
+ currentLine < offset + limit
424
+ ) {
425
+ linesRead += 1
426
+ }
427
+ if (!intervalComplete && inspectedBytes === byteCap && currentLine < offset + limit) {
428
+ throw new Error('BUDGET_EXCEEDED')
429
+ }
430
+ try {
431
+ new TextDecoder('utf-8', { fatal: true }).decode(
432
+ Buffer.concat(inspectedChunks, inspectedBytes),
433
+ )
434
+ } catch {
435
+ throw new Error('PATH_DENIED')
436
+ }
437
+ const bytes = Buffer.concat(chunks, selectedBytes)
438
+ let content: string
439
+ try {
440
+ content = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
441
+ } catch {
442
+ throw new Error('PATH_DENIED')
443
+ }
444
+ return { content, linesRead, bytes: selectedBytes }
445
+ } finally {
446
+ await file?.close().catch(() => undefined)
447
+ }
448
+ }
449
+
450
+ /** Build one registered tool and its in-memory, session-lifecycle state. */
451
+ export function createV2BoundedReadTool(dependencies: BoundedReadDependencies): {
452
+ definition: V2ToolDef
453
+ origins: V2BoundedReadOriginRegistry
454
+ clearSession: (sessionID: string) => void
455
+ } {
456
+ const origins = new V2BoundedReadOriginRegistry()
457
+ const budgets = new Map<string, Budget>()
458
+
459
+ const reserve = (
460
+ sessionID: string,
461
+ messageID: string,
462
+ root: string,
463
+ args: ReadArgs,
464
+ ): string | undefined => {
465
+ const key = budgetKey(sessionID, messageID)
466
+ let budget = budgets.get(key)
467
+ if (budget === undefined) {
468
+ if (budgets.size >= MAX_SESSIONS) return 'UNAVAILABLE'
469
+ budget = {
470
+ sessionID,
471
+ messageID,
472
+ root,
473
+ files: new Set(),
474
+ calls: 0,
475
+ lines: 0,
476
+ bytes: 0,
477
+ sealed: false,
478
+ }
479
+ budgets.set(key, budget)
480
+ }
481
+ if (budget.root !== root || budget.sealed) return 'SCOPE_DENIED'
482
+ if (budget.files.has(args.path)) return 'SCOPE_DENIED'
483
+ if (
484
+ budget.calls + 1 > BOUNDED_READ_MAX_CALLS ||
485
+ budget.files.size + 1 > BOUNDED_READ_MAX_FILES ||
486
+ budget.lines + args.limit > BOUNDED_READ_MAX_LINES ||
487
+ budget.bytes + BYTES_PER_CALL > BOUNDED_READ_MAX_BYTES
488
+ ) {
489
+ budget.sealed = true
490
+ return 'BUDGET_EXCEEDED'
491
+ }
492
+ budget.calls += 1
493
+ budget.files.add(args.path)
494
+ budget.lines += args.limit
495
+ budget.bytes += BYTES_PER_CALL
496
+ return undefined
497
+ }
498
+
499
+ const definition: V2ToolDef = {
500
+ name: BOUNDED_READ_TOOL,
501
+ description:
502
+ 'Read a small line range from one exact text file inside the current project. Root Zeus only; two files and 400 lines total per assistant message. Denies recognized sensitive scopes and known binary/media formats; returns only bounded valid UTF-8 text after secret scanning. No directories or fallback reading.',
503
+ input: {
504
+ type: 'object',
505
+ additionalProperties: false,
506
+ required: ['path', 'offset', 'limit'],
507
+ properties: {
508
+ path: {
509
+ type: 'string',
510
+ minLength: 1,
511
+ description:
512
+ 'Exact relative file path inside the current project; no globs or traversal.',
513
+ },
514
+ offset: {
515
+ type: 'integer',
516
+ minimum: 1,
517
+ description: 'First line to return, starting at 1.',
518
+ },
519
+ limit: {
520
+ type: 'integer',
521
+ minimum: 1,
522
+ maximum: BOUNDED_READ_MAX_LINES,
523
+ description: 'Number of lines requested; at most 400 lines total across this turn.',
524
+ },
525
+ },
526
+ },
527
+ output: {},
528
+ async execute(rawInput: Record<string, unknown>, rawContext: unknown) {
529
+ const args = parseArgs(rawInput)
530
+ if (args === undefined) return errorResult('ARGUMENT_INVALID')
531
+ if (!isRecord(rawContext)) return errorResult('BOUND_READ_UNAUTHORIZED')
532
+ const sessionID = nonEmpty(rawContext.sessionID)
533
+ const messageID = nonEmpty(rawContext.messageID)
534
+ const callID = nonEmpty(rawContext.id)
535
+ const agent = nonEmpty(rawContext.agent)
536
+ if (!sessionID || !messageID || !callID || agent !== 'zeus') {
537
+ return errorResult('BOUND_READ_UNAUTHORIZED')
538
+ }
539
+ const origin = origins.consume({
540
+ sessionID,
541
+ messageID,
542
+ callID,
543
+ agent,
544
+ relativePath: args.path,
545
+ })
546
+ if (origin === undefined) return errorResult('BOUND_READ_UNAUTHORIZED')
547
+
548
+ let sessionValue: unknown
549
+ try {
550
+ sessionValue = await dependencies.getSession(sessionID)
551
+ } catch {
552
+ return errorResult('BOUND_READ_UNAUTHORIZED')
553
+ }
554
+ if (!isRecord(sessionValue)) return errorResult('BOUND_READ_UNAUTHORIZED')
555
+ const session = sessionValue as BoundedReadHostSession
556
+ if (
557
+ session.id !== sessionID ||
558
+ hasParent(session) ||
559
+ session.projectID !== dependencies.projectID
560
+ ) {
561
+ return errorResult('BOUND_READ_UNAUTHORIZED')
562
+ }
563
+
564
+ // The caller supplies the realpath captured before registration. This is
565
+ // pure lexical work, so quota reservation still precedes filesystem I/O.
566
+ const root = path.resolve(dependencies.rootDirectory)
567
+ const target = path.resolve(root, ...args.path.split('/'))
568
+ if (!isWithin(root, target) || sensitivePath(args.path)) return errorResult('PATH_DENIED')
569
+ const reservationError = reserve(sessionID, messageID, root, args)
570
+ if (reservationError !== undefined) return errorResult(reservationError)
571
+
572
+ try {
573
+ await dependencies.assertPermission({
574
+ sessionID,
575
+ agent,
576
+ action: 'read',
577
+ resources: [target],
578
+ save: [],
579
+ source: { type: 'tool', messageID, id: callID },
580
+ })
581
+ } catch {
582
+ return errorResult('TOOL_DENIED')
583
+ }
584
+
585
+ const rootInfo = await lstat(root).catch(() => undefined)
586
+ if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) return errorResult('PATH_DENIED')
587
+ const components = args.path.split('/')
588
+ let cursor = root
589
+ for (const [index, component] of components.entries()) {
590
+ cursor = path.join(cursor, component)
591
+ const info = await lstat(cursor).catch(() => undefined)
592
+ if (info === undefined || info.isSymbolicLink()) return errorResult('PATH_DENIED')
593
+ if (index < components.length - 1 && !info.isDirectory()) return errorResult('PATH_DENIED')
594
+ if (index === components.length - 1 && !info.isFile()) return errorResult('PATH_DENIED')
595
+ }
596
+ const targetReal = await realpath(target).catch(() => undefined)
597
+ if (targetReal === undefined || !isWithin(root, targetReal)) return errorResult('PATH_DENIED')
598
+
599
+ try {
600
+ const result = await readLineWindow(targetReal, args.offset, args.limit, BYTES_PER_CALL)
601
+ const scanDecision = await dependencies.scanContent({
602
+ content: result.content,
603
+ agent,
604
+ sessionID,
605
+ messageID,
606
+ callID,
607
+ })
608
+ if (scanDecision !== 'allow') {
609
+ return errorResult(scanDecision === 'deny' ? 'SCOPE_DENIED' : 'UNAVAILABLE')
610
+ }
611
+ return {
612
+ output: JSON.stringify({
613
+ path: args.path,
614
+ offset: args.offset,
615
+ linesRead: result.linesRead,
616
+ bytes: result.bytes,
617
+ status: result.linesRead === 0 ? 'empty' : 'ok',
618
+ content: result.content,
619
+ }),
620
+ }
621
+ } catch (error) {
622
+ const code = error instanceof Error ? error.message : ''
623
+ return errorResult(
624
+ ['CHANGED', 'BUDGET_EXCEEDED', 'PATH_DENIED'].includes(code) ? code : 'UNAVAILABLE',
625
+ )
626
+ }
627
+ },
628
+ }
629
+
630
+ return {
631
+ definition,
632
+ origins,
633
+ clearSession(sessionID) {
634
+ for (const [key, budget] of budgets) {
635
+ if (budget.sessionID === sessionID) budgets.delete(key)
636
+ }
637
+ origins.clearSession(sessionID)
638
+ },
639
+ }
640
+ }