Experimental Claude Code mods for GraphOS Agent Services. GraphOS Inspector explains each execute call in a docked pane before you approve it, shows what came…

<a href="https://github.com/inanna-apollo/graphos-agent-mods"><img src="docs/qr.svg" align="right" width="140" alt="QR code for github.com/inanna-apollo/graphos-agent-mods"></a>
GraphOS Inspector helps you review GraphOS Agent Services calls in Claude Code. It shows the operation, arguments, Agent Services policy checks, results, and a preview of proposed writes beside the permission dialog.
Experimental. Not a supported Apollo product. Works with GraphOS Agent Services only, not the open-source Apollo MCP Server.
Use Claude Code 2.1.290 or later, on macOS or Linux, in the terminal or Claude Desktop's Code tab. Connect the claude.ai GraphOS Agent Services connector first; /mcp should list it.
/plugin marketplace add inanna-apollo/graphos-agent-mods
/plugin install graphos-agent-mods@graphos-experiments
Optional: allow Agent Services' read-only search, introspect, validate, and dry_run tools in /permissions (or choose "don't ask again" the first time Claude uses one). The inspector uses them for policy, schema, and validity checks; without them the pane still explains the parsed operation and marks those checks as not run. /gas setup reports anything missing.
Try asking: “Find the five most recently updated open issues in DEV and show their summary and status.” The pane opens automatically in a wide terminal, or run /gas to open it. If the command is missing, restart Claude Code or run /reload-plugins, then check /plugin to confirm the plugin is enabled.
ask into an allow; they cannot override a deny, organization limits, or Agent Services policy.Data sent for a headline: when a call is eligible for a new summary, the inspector sends the operation structure, argument values, and schema descriptions to Haiku through your Claude Code sign-in. Argument values are capped at 2,000 characters. The request may happen while the permission dialog is open, even for a call you later refuse. The response is not sent to Haiku. Headlines are best-effort and may be reused from cache. Policy decisions and verdicts are computed from the operation and Agent Services checks, not from the headline model.
The inspector never calls execute itself. It calls only search, introspect, validate, and dry_run, and only when your settings allow those tools. Setup prompts and access requests are drafts for you to review and send. Nothing else leaves your machine: the plugin has no telemetry.
/gas opens the pane on the current or last Agent Services call; /gas setup reports anything missing. The user guide lists every command.
In /plugin, open Marketplaces, select graphos-experiments, choose Update marketplace, then run /reload-plugins. The user guide has the shell commands and auto-update.
The terminal and Desktop Code tab are the tested surfaces. The VS Code extension, mobile app, claude -p, and Windows are untested. Hover cards need terminal mouse support. Links open through open on macOS or xdg-open on Linux. Custom Atlassian or Slack domains must be configured by hand. Only the claude.ai connector is inspected (tools named mcp__claude_ai_GraphOS_Agent_Services__*); Agent Services added as another MCP server is not, and no other server's calls pass through the plugin.
See the user guide for write previews, trust-rule syntax, link settings, and detailed behavior. See contributor guidance to work on the mod. The project is under the Elastic License 2.0; questions and bug reports belong in this repository's issue tracker.
hooks/register.tsx 1487 lines1// GraphOS Inspector: explains each Agent Services execute call while you decide on it.
2// The only module that touches $. It never denies or rewrites a call: every
3// tool.call path ends in next(e) with e unchanged. It approves only a call
4// that fits the person's own trust rules (src/trust.ts), and only where the
5// engine would have asked.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, RenderNode, RenderSurface, Timer } from 'claude-code'
9
10import type { InspectedCall, InspectorCalls } from '../types'
11import { adaptExecute } from '../src/adapter.ts'
12import { escapeText } from '../src/escape.ts'
13import { annotate } from '../src/annotate.ts'
14import { buildIR } from '../src/build.ts'
15import { cacheForServer, enrich, isAllowedWithoutPrompt, scopesOf } from '../src/enrich.ts'
16import type { CallGas, EnrichCache } from '../src/enrich.ts'
17import { normalize } from '../src/normalize.ts'
18import { allowedOrigins, configOf, loadLinkConfig } from '../src/links.ts'
19import type { BaseSource, LinkConfig } from '../src/links.ts'
20import { describeBases, hostOf, learnable, learnedOf, setupPrompt, sitesIn, sitesOfGraph } from '../src/sites.ts'
21import type { Sites } from '../src/sites.ts'
22import { setupReport } from '../src/setup.ts'
23import type { ToolState } from '../src/setup.ts'
24import { versionNote } from '../src/version.ts'
25import { OPEN_TIMEOUT_MS, openArgv } from '../src/open.ts'
26import { arrive, normalizeCalls, EMPTY, isFinal, navOf, newer, older, patchAgent, patchIR, patchOutcome, patchTrust, settle, settleOrphans, shownAt, statusOf } from '../src/queue.ts'
27import { MAX_SAVED, outcomeOf, pickResult, responseIn, savedOutcome, truncationOf } from '../src/result.ts'
28import { gasServers, splitMcpTool } from '../src/servers.ts'
29import { buildPrompt, cacheKey, MAX_TOKENS, parseSummary, SYSTEM_PROMPT } from '../src/summary.ts'
30import type { CallIR, Summary } from '../src/ir.ts'
31import { CLOSED, RawView, viewOf } from '../src/view.tsx'
32import { noticeOf } from '../src/view/notice.ts'
33import { trustVerdictOf } from '../src/view/trust.ts'
34import { describeRules, fitCall, parseTrust } from '../src/trust.ts'
35import { agentOf, pluginOf } from '../src/view/agent.ts'
36import { spinnerTextOf } from '../src/view/spinner.ts'
37import { ResultBlockView, ResultRows } from '../src/view/transcript.tsx'
38import type { TranscriptKit } from '../src/view/transcript.tsx'
39import { resultBlockOf } from '../src/view/transcript.ts'
40import type { ResultBlock } from '../src/view/transcript.ts'
41import { statusLine } from '../src/view/status.ts'
42import type { Fit, TrustRule } from '../src/trust.ts'
43import { COLOR } from '../src/view/ui/theme.ts'
44import type { Kit } from '../src/view/kit.ts'
45import { rawTitleOf } from '../src/view/raw.tsx'
46import type { PaneChange } from '../src/view.tsx'
47import { snapshotOptionsOf } from '../src/snapshot/options.ts'
48import { findCall, planSnapshots } from '../src/snapshot/write.ts'
49import type { SnapshotOptions } from '../src/snapshot/options.ts'
50
51const PANE = 'gas'
52// The claude.ai connector's `execute` (src/servers.ts CONNECTOR; the hook matchers spell it inline): no other server's calls reach the hooks.
53const EXECUTE_TOOL = /^mcp__claude_ai_GraphOS_Agent_Services__execute$/
54const TITLE = 'GraphOS Inspector'
55// The raw operation of the call shown, in its own docked pane.
56const RAW_PANE = 'gas-raw'
57// The content is laid out for about 60 columns: ask the dock for that much and
58// leave the rest to the transcript. A width the person dragged to still wins.
59const DOCK_COLUMNS = 64
60const calls = atom({ plugin: 'graphos-agent-mods', key: 'calls' } as const, EMPTY)
61const hasAutoOpened = atom({ plugin: 'graphos-agent-mods', key: 'hasAutoOpened' } as const, false)
62const pumper = atom({ plugin: 'graphos-agent-mods', key: 'pumper' } as const, '')
63const tally = atom({ plugin: 'graphos-agent-mods', key: 'tally' } as const, { calls: 0, unasked: 0, bytes: 0 })
64const spinner = atom({ plugin: 'graphos-agent-mods', key: 'spinner' } as const, { id: '', text: '' })
65
66/** The calls state, normalized: stored state can predate this code (a reload keeps it). */
67async function readCalls($: EngineInterface): Promise<InspectorCalls> {
68 const raw = await read($, calls)
69 return normalizeCalls(raw)
70}
71
72/** Updates the calls state; the callback only ever sees the normalized shape. */
73function updateCalls($: EngineInterface, change: (state: InspectorCalls) => InspectorCalls) {
74 return update($, calls, current => change(normalizeCalls(current)))
75}
76
77/** Is the raw pane open? The engine's record, not ours: it closes on Esc too. */
78async function isRawOpen($: EngineInterface): Promise<boolean> {
79 try {
80 return (await $.ui.panes()).some(pane => pane.id === RAW_PANE)
81 } catch {
82 return false
83 }
84}
85
86/** The call the main pane shows right now. */
87async function shownCall($: EngineInterface) {
88 return shownAt(await readCalls($), (await read($, paneUi)).cursor ?? null).call
89}
90
91/** Opens the raw pane titled for the call shown, or closes it; true when it is open after. */
92async function toggleRaw($: EngineInterface): Promise<boolean> {
93 if (await isRawOpen($)) {
94 await $.ui.close({ id: RAW_PANE })
95 return false
96 }
97 const opened = await $.ui.open({ id: RAW_PANE, title: rawTitleOf(await shownCall($)), focus: true, closeOnEscape: true, columns: DOCK_COLUMNS })
98 return opened.isPlaced
99}
100
101/** Keeps an open raw pane's title on the call it shows (opening again retitles in place). */
102async function retitleRaw($: EngineInterface) {
103 try {
104 if (await isRawOpen($)) await $.ui.open({ id: RAW_PANE, title: rawTitleOf(await shownCall($)) })
105 } catch {
106 // Best effort: a stale title only.
107 }
108}
109
110const paneUi = atom({ plugin: 'graphos-agent-mods', key: 'paneUi' } as const, CLOSED)
111
112// Cache server discovery to avoid listing tools on every transcript redraw.
113// Negative results expire after SERVERS_TTL_MS. Actual calls refresh the list
114// because a server may have connected since the previous lookup.
115const SERVERS_TTL_MS = 30_000
116let servers: Set<string> | undefined
117// When a listing last said each server is not Agent Services ($.clock time).
118const misses = new Map<string, number>()
119// The listing in flight, shared by every row drawing at once.
120let listing: Promise<Set<string>> | undefined
121// Schema and scope lookups, valid for one bundleDigest (enrich clears it on a change).
122const caches = new Map<string, EnrichCache>()
123// Calls already claimed by this instance's analysis pump. Enrichment handles
124// its own bounded retry when the server's schema generation changes.
125const attempted = new Set<string>()
126// Summaries by cacheKey (operation, variables, bundleDigest, prompt); null
127// when Haiku's answer failed the checks, so a rejected one is not re-asked.
128const summaries = new Map<string, Summary | null>()
129
130// Surfaces where a Client (the live line) failed, until a reload.
131const faulted = new Set<string>()
132
133// A text file per distinct rendering of each call (src/snapshot), a design-review tool. Its two options are not in the manifest, so a person never sees them: unset is the default (off, 50/64/84 columns), and `/gas snapshots on|off` turns it on.
134let snapshots: SnapshotOptions = { isOn: false, widths: [] }
135
136/** Writes the plan for one call; resolves with its folder, undefined when nothing was written. Never rejects. */
137async function emitSnapshot($: EngineInterface, call: InspectedCall, waiting: number, stage: string, widths: number[], force: boolean): Promise<string | undefined> {
138 try {
139 const plan = planSnapshots({ call, waiting, stage, widths, force })
140 if (plan === undefined) return undefined
141 const when = new Date(await $.clock.now()).toISOString()
142 for (const file of plan.files) {
143 await $.fs.write(`${$.plugin.root}/snapshots/${plan.dir}/${file.name}`, `# stage: ${stage} | status: ${call.status} | ${when} | ${file.cols} columns\n${file.body}\n`)
144 }
145 return `${$.plugin.root}/snapshots/${plan.dir}`
146 } catch {
147 return undefined
148 }
149}
150
151// `/gas snapshots on|off`, kept in $.store across sessions.
152const SNAPSHOTS_KEY = 'snapshots'
153
154async function isSnapshotting($: EngineInterface): Promise<boolean> {
155 if (snapshots.isOn) return true
156 try {
157 return (await $.store.get(SNAPSHOTS_KEY)) === true
158 } catch {
159 return false
160 }
161}
162
163/** After a state change of call `id`, when snapshots are on. */
164async function snap($: EngineInterface, id: string, stage: string) {
165 if (!(await isSnapshotting($))) return
166 try {
167 const state = await readCalls($)
168 const call = findCall(state, id)
169 if (call !== undefined) await emitSnapshot($, call, state.queue.length, stage, snapshots.widths, false)
170 } catch {
171 // Best effort.
172 }
173}
174
175const PUMP_MS = 200
176// This module instance. A hot reload may leave the previous instance's timer
177// running and may skip session.start (seen in other mods), so the newest
178// instance claims the pump in $.state and an older timer stops at its next tick.
179const INSTANCE = `${Math.random()}`.slice(2)
180// Runs only while a call waits for its analysis to start: started when a call
181// arrives (and once at session.start, for calls a reload left behind), stopped
182// at the first tick that has started them all. An idle session never polls.
183let pumpTimer: Timer | undefined
184// Starts the pump on the session's `$` (set at session.start, dropped at session.end).
185let wakePump: (() => void) | undefined
186
187/**
188 * (Re)starts this instance's pump on `$` and claims it; called once a call's
189 * arrival is written. A tool.call's `$` reads one moment while its dialog is
190 * up (its own writes included), so the session's is used where there is one.
191 * Never waits: the timer is set before it returns.
192 */
193function startPump($: EngineInterface) {
194 if (pumpTimer !== undefined) stopPump(pumpTimer)
195 const claim = update($, pumper, () => INSTANCE).then(
196 () => true,
197 () => false,
198 )
199 const timer = $.clock.every(PUMP_MS, () => void tick($, timer, claim).catch(() => undefined))
200 pumpTimer = timer
201}
202
203function stopPump(timer: Timer) {
204 timer.cancel()
205 if (pumpTimer === timer) pumpTimer = undefined
206}
207
208/** One period of the pump: starts every analysis that waits, then stops; a read that failed tries again next period. */
209async function tick($: EngineInterface, timer: Timer, claim: Promise<boolean>) {
210 // A newer instance (a hot reload) claimed the pump: this one stops.
211 if ((await claim) && (await read($, pumper)) !== INSTANCE) return stopPump(timer)
212 await pump($)
213 stopPump(timer)
214}
215
216const READ_ONLY_GAS_TOOLS: ReadonlySet<string> = new Set(['search', 'introspect', 'validate', 'dry_run'])
217
218/**
219 * Only read-only Agent Services tools go through here, on the same server the call uses.
220 *
221 * A plugin's $.mcp.call goes through the same permission check as the
222 * model's tool calls: outside auto mode it raises its own dialog, queued
223 * behind the one the user is deciding on (seen live, see docs/spikes.md). The mod
224 * must never interrupt, so it calls a tool only when the user's rules
225 * already allow it; anything else stays unknown in the pane.
226 */
227function gasCaller($: EngineInterface, server: string): CallGas {
228 return async (tool, args) => {
229 // Hard guard, not just a type: the mod never runs a GraphQL operation.
230 if (!READ_ONLY_GAS_TOOLS.has(tool)) throw new Error(`GraphOS Inspector never calls ${String(tool)}`)
231 const name = `mcp__${server}__${tool}`
232 // Allowed by the person's rules, and not capped below allow by their organization.
233 const verdict = await $.tool.check({ tool: name, input: args })
234 const { decision, ceiling } = verdict
235 if (!isAllowedWithoutPrompt(verdict)) throw new NotAllowed(name, ceiling !== undefined && ceiling !== 'allow' ? ceiling : decision)
236 return $.mcp.call(server, tool, args)
237 }
238}
239
240class NotAllowed extends Error {
241 constructor(
242 readonly tool: string,
243 readonly decision: string,
244 ) {
245 super(`${tool} is not allowed without a prompt (${decision})`)
246 }
247}
248
249function variablesOf(call: InspectedCall): Record<string, unknown> {
250 try {
251 const parsed: unknown = call.variables === '' ? {} : JSON.parse(call.variables)
252 return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
253 } catch {
254 return {}
255 }
256}
257
258// Dev timing, off unless `/gas timing on` (kept in $.store). The newest
259// entries live in memory and the file is rewritten whole, one write at a
260// time, so there is no read-modify-write race and the file stays small.
261const TIMING_KEY = 'timing'
262const TIMING_KEEP = 50
263const timingLines: string[] = []
264let timingWrite: Promise<void> = Promise.resolve()
265
266async function isTiming($: EngineInterface): Promise<boolean> {
267 try {
268 return (await $.store.get(TIMING_KEY)) === true
269 } catch {
270 return false
271 }
272}
273
274/** One JSON line per summary in the plugin folder (git-ignored). Never blocks or fails the summary. */
275function logTiming($: EngineInterface, entry: Record<string, unknown>) {
276 timingLines.push(JSON.stringify(entry))
277 if (timingLines.length > TIMING_KEEP) timingLines.shift()
278 const body = `${timingLines.join('\n')}\n`
279 timingWrite = timingWrite.then(() => $.fs.write(`${$.plugin.root}/timing.log`, body)).catch(() => undefined)
280}
281
282/**
283 * One Haiku summary of an enriched call, from the IR only (never the raw
284 * operation). parseSummary rejects an answer with no usable headline before
285 * it is stored; a rejected or failed answer leaves the deterministic
286 * headline in place.
287 */
288async function summarize($: EngineInterface, call: InspectedCall, enrichMs: number) {
289 const key = await cacheKey(call.ir, variablesOf(call))
290 let summary = summaries.get(key)
291 if (summary === undefined) {
292 const prompt = buildPrompt(call.ir)
293 const started = await $.clock.now()
294 const stop = new AbortController()
295 const answer = await withinStep($, 'Haiku', $.model.complete({ model: 'haiku', system: SYSTEM_PROMPT, prompt, maxTokens: MAX_TOKENS, effort: 'low', timeoutMs: 8_000 }, { signal: stop.signal })).finally(() => stop.abort())
296 const parsed = answer.isAnswered ? parseSummary(answer.text) : undefined
297 summary = parsed?.summary ?? null
298 if (await isTiming($)) logTiming($, {
299 at: started,
300 root: call.ir.roots.map(root => root.name).join(','),
301 sinceArrivedMs: started - call.arrivedAt,
302 enrichMs,
303 haikuMs: (await $.clock.now()) - started,
304 systemChars: SYSTEM_PROMPT.length,
305 promptChars: prompt.length,
306 usage: answer.usage,
307 ...(answer.isAnswered ? { rejected: parsed?.rejected ?? null } : { reason: answer.reason }),
308 ...(answer.isAnswered && parsed?.rejected !== undefined && { text: answer.text.slice(0, 200) }),
309 })
310 // Cache real answers only (an API error may pass); keep the newest 200.
311 if (answer.isAnswered) {
312 summaries.set(key, summary)
313 const oldest = summaries.size > 200 ? summaries.keys().next().value : undefined
314 if (oldest !== undefined) summaries.delete(oldest)
315 }
316 }
317 if (summary === null) return
318 const found = summary
319 await updateCalls($, current => {
320 const latest = [...current.queue, ...current.history].find(one => one.id === call.id)
321 return latest === undefined ? current : patchIR(current, call.id, { ...latest.ir, summary: found })
322 })
323 inSpinnerOrder(() => headlineSpinner($, call.id, { ...call.ir, summary: found }))
324 await snap($, call.id, 'summary')
325}
326
327// Timeout for each analysis stage: Agent Services checks and the headline.
328const STEP_MS = 20_000
329
330class StepTimeout extends Error {
331 constructor(readonly step: string) {
332 super(`no answer from ${step} in ${STEP_MS / 1000} s`)
333 }
334}
335
336/**
337 * `work`, or a StepTimeout once `ms` have passed on $.clock. The work itself
338 * runs on (an MCP call cannot be cut); only the waiting ends. A clock that
339 * cannot sleep sets no bound.
340 */
341function withinStep<T>($: EngineInterface, step: string, work: Promise<T>, ms = STEP_MS): Promise<T> {
342 const stop = new AbortController()
343 const timeout = new Promise<never>((_, reject) => {
344 $.clock.sleep(ms, { signal: stop.signal }).then(
345 () => reject(new StepTimeout(step)),
346 () => undefined,
347 )
348 })
349 return Promise.race([work, timeout]).finally(() => stop.abort())
350}
351
352/**
353 * The IR of a call whose analysis failed or ran out of time: settled
354 * (`partial`), every check marked failed with the reason, policy left unknown.
355 */
356function failedIR(ir: CallIR, error: unknown): CallIR {
357 const reason = error instanceof StepTimeout ? error.message : `analysis failed: ${error instanceof Error ? error.message : String(error)}`
358 const checks = { policy: 'failed', validation: 'failed', schema: 'failed', error: reason.slice(0, 160) } as const
359 const { isSummarizing: _dropped, ...settled } = annotate(ir, { isIncomplete: true, checks })
360 return settled.state === 'analyzing' ? { ...settled, state: 'partial', checks } : settled
361}
362
363/** A call the pump should analyse: its checks or its headline are still out, and this instance has not tried it. */
364const needsAnalysis = (call: InspectedCall) => (call.ir.state === 'analyzing' || call.ir.isSummarizing === true) && !attempted.has(call.id)
365
366/**
367 * One call's analysis, start to end, detached from every other call's: the
368 * Agent Services checks, then the Haiku headline, each bounded by STEP_MS. However it
369 * ends, the call leaves `analyzing` and stops shimmering, and it is never
370 * retried by this instance.
371 */
372async function analyse($: EngineInterface, call: InspectedCall) {
373 let failure: unknown
374 try {
375 let ir = call.ir
376 let enrichMs = 0
377 if (ir.state === 'analyzing') {
378 const started = await $.clock.now()
379 try {
380 ir = await withinStep($, 'Agent Services', enrich(gasCaller($, call.server), cacheForServer(caches, call.server), ir, call.operation, variablesOf(call)))
381 } catch (error) {
382 ir = failedIR(ir, error)
383 }
384 enrichMs = (await $.clock.now()) - started
385 }
386 // Summarize once the IR carries what the summary must describe; the pane shimmers the headline meanwhile.
387 const willSummarize = ir.state !== 'unparseable' && ir.summary === undefined
388 const enriched = ir
389 await updateCalls($, current => patchIR(current, call.id, willSummarize ? { ...enriched, isSummarizing: true } : enriched))
390 void snap($, call.id, 'enriched')
391 if (willSummarize) {
392 try {
393 await summarize($, { ...call, ir }, enrichMs)
394 } catch {
395 // Haiku failed, answered garbage or ran out of time: the deterministic headline stays.
396 }
397 }
398 } catch (error) {
399 failure = error
400 } finally {
401 // Never leave the call analysing or shimmering, whatever failed above.
402 await updateCalls($, current => {
403 const latest = findCall(current, call.id)
404 if (latest === undefined) return current
405 if (latest.ir.state === 'analyzing') return patchIR(current, call.id, failedIR(latest.ir, failure ?? new Error('stopped')))
406 if (latest.ir.isSummarizing !== true) return current
407 const { isSummarizing: _done, ...rest } = latest.ir
408 return patchIR(current, call.id, rest)
409 }).catch(() => undefined)
410 void snap($, call.id, 'ready')
411 void recordVerdict($, call.id)
412 }
413}
414
415/**
416 * Starts the analysis of every call that needs one, each on its own: a slow
417 * Haiku answer or a hung Agent Services call holds up no other call. Runs on a clock
418 * tick, its own dispatch, apart from the tool.call hook and its dialog.
419 */
420async function pump($: EngineInterface) {
421 let state = await readCalls($)
422 // Pending calls whose hook went with an older instance settle as interrupted: they can hold the pane no longer.
423 const orphans = state.queue.filter(call => call.owner !== INSTANCE)
424 if (orphans.length > 0) {
425 state = await updateCalls($, current => settleOrphans(current, INSTANCE))
426 // No hook is left to end their spinner text.
427 for (const orphan of orphans) endSpinner($, orphan.id)
428 }
429 const all = [...state.queue, ...(state.last === null ? [] : [state.last]), ...state.history]
430 // Forget calls that left the state, so the set stays bounded.
431 const live = new Set(all.map(call => call.id))
432 for (const id of attempted) if (!live.has(id)) attempted.delete(id)
433 for (const call of all) {
434 if (!needsAnalysis(call)) continue
435 // Claimed before any await: a later tick never starts it again.
436 attempted.add(call.id)
437 void analyse($, call).catch(() => undefined)
438 }
439}
440
441/** The verdict line (src/view/notice.ts) of each Agent Services execute call among a row's calls, in order. */
442async function verdictsOf($: EngineInterface, calls: readonly { tool: string; tool_use_id?: string }[]): Promise<string[]> {
443 const ids: string[] = []
444 for (const call of calls) {
445 const id = call.tool_use_id
446 if (id === undefined) continue
447 // A recorded verdict is an Agent Services call's: no server lookup.
448 if (verdicts.has(id)) {
449 ids.push(id)
450 continue
451 }
452 const server = EXECUTE_TOOL.test(call.tool) ? splitMcpTool(call.tool)?.server : undefined
453 if (server !== undefined && (await isGasServer($, server))) ids.push(id)
454 }
455 if (ids.length === 0) return []
456 // Settled rows read the record and so subscribe to nothing; a reload reads the kept copy once.
457 if (ids.some(id => !verdicts.has(id))) await loadVerdicts($)
458 const open = ids.filter(id => !verdicts.has(id))
459 if (open.length > 0) {
460 // Only a call still in play reads the calls state, which redraws its row as its analysis lands.
461 const state = await readCalls($)
462 for (const id of open) {
463 const call = findCall(state, id)
464 if (call !== undefined && isFinal(call)) rememberVerdict(id, verdictOf(call))
465 }
466 return ids.flatMap(id => {
467 if (verdicts.has(id)) return verdicts.get(id) ?? []
468 const call = findCall(state, id)
469 return (call === undefined ? undefined : verdictOf(call)) ?? []
470 })
471 }
472 return ids.flatMap(id => verdicts.get(id) ?? [])
473}
474
475/** The verdict line of a call (src/view/notice.ts): with the outcome once it ran. */
476const verdictOf = (call: InspectedCall) => {
477 // Lead with trust-rule attribution and avoid repeating the checkmark.
478 const trusted = trustVerdictOf(call.trust)
479 const notice = noticeOf(call.ir, call.status === 'ran' ? call.outcome : undefined)
480 const parts = [trusted, trusted !== undefined && notice?.startsWith('✓ ') === true ? notice.slice(2) : notice].filter(part => part !== undefined)
481 return parts.length === 0 ? null : parts.join(' · ')
482}
483
484// Each settled Agent Services call's final verdict line by tool_use_id (null: it has
485// none), newest last: the record a row keeps however long ago its call left
486// the history. Mirrored in $.state (`verdicts`) so a reload keeps it.
487const VERDICTS_MAX = 500
488const verdicts = new Map<string, string | null>()
489let verdictsLoad: Promise<void> | undefined
490const verdictLog = atom({ plugin: 'graphos-agent-mods', key: 'verdicts' } as const, {})
491
492function rememberVerdict(id: string, text: string | null) {
493 verdicts.delete(id)
494 verdicts.set(id, text)
495 while (verdicts.size > VERDICTS_MAX) {
496 const oldest = verdicts.keys().next().value
497 if (oldest === undefined) break
498 verdicts.delete(oldest)
499 }
500}
501
502/** Reads the kept record into memory once per instance; what memory has already wins. */
503function loadVerdicts($: EngineInterface): Promise<void> {
504 verdictsLoad ??= read($, verdictLog).then(
505 log => {
506 for (const [id, text] of Object.entries(log)) if (!verdicts.has(id) && (typeof text === 'string' || text === null)) rememberVerdict(id, text)
507 },
508 () => undefined,
509 )
510 return verdictsLoad
511}
512
513/**
514 * Records call `id`'s verdict once it is final (src/queue.ts isFinal), in
515 * memory and in $.state. Called where a call settles and where its analysis
516 * ends, never while drawing. Best effort.
517 */
518async function recordVerdict($: EngineInterface, id: string) {
519 try {
520 const call = findCall(await readCalls($), id)
521 if (call === undefined || !isFinal(call)) return
522 const text = verdictOf(call)
523 rememberVerdict(id, text)
524 rememberResultBlock(id, resultBlockOfCall(call))
525 await update($, verdictLog, log => {
526 if (log[id] === text) return log
527 const { [id]: _replaced, ...rest } = log
528 const kept = Object.entries(rest).slice(-(VERDICTS_MAX - 1))
529 return { ...Object.fromEntries(kept), [id]: text }
530 })
531 } catch {
532 // The row reads the calls state instead.
533 }
534}
535
536// Each settled Agent Services call's RESULT block for the transcript (src/view/transcript.ts)
537// by tool_use_id (null: it has none), newest last: the record a settled row
538// reads, so it redraws for no other call's writes, as a verdict does. Kept in
539// memory only: a reload finds the last 20 calls' outcomes in the calls state and
540// draws no block for an older row (the engine's own drawing stays).
541const RESULT_BLOCKS_MAX = 200
542const resultBlocks = new Map<string, ResultBlock | null>()
543
544function rememberResultBlock(id: string, block: ResultBlock | null) {
545 resultBlocks.delete(id)
546 resultBlocks.set(id, block)
547 while (resultBlocks.size > RESULT_BLOCKS_MAX) {
548 const oldest = resultBlocks.keys().next().value
549 if (oldest === undefined) break
550 resultBlocks.delete(oldest)
551 }
552}
553
554/** A settled call's block: only one that ran and whose response was read has one. */
555const resultBlockOfCall = (call: InspectedCall): ResultBlock | null => (call.status === 'ran' && call.outcome !== undefined ? (resultBlockOf(call.outcome, call.ir, linkConfig) ?? null) : null)
556
557/**
558 * The block under an Agent Services call's result row: from the record, which the call's
559 * settling and the end of its analysis write (recordVerdict, and the hook where
560 * it settles), else from the calls state, which redraws the row as the call lands.
561 */
562async function resultBlockOfRow($: EngineInterface, tool: string, id: string): Promise<ResultBlock | undefined> {
563 if (resultBlocks.has(id)) return resultBlocks.get(id) ?? undefined
564 const server = splitMcpTool(tool)?.server
565 if (server === undefined || !(await isGasServer($, server))) return undefined
566 const call = findCall(await readCalls($), id)
567 // A call that left the state (a long session, a reload) has nothing to read again: recorded, so the row stops listening to the calls state.
568 if (call === undefined) {
569 rememberResultBlock(id, null)
570 return undefined
571 }
572 if (!isFinal(call)) return undefined
573 const block = resultBlockOfCall(call)
574 rememberResultBlock(id, block)
575 return block ?? undefined
576}
577
578/**
579 * Markdown whose links the surface opens as it opens any link: a press
580 * handler and its link list are left off. Where the plugin cannot open a
581 * browser itself (a remote surface; $.process runs on the CLI only).
582 */
583function surfaceOpened(Markdown: Kit['Markdown'] & {}): NonNullable<Kit['Markdown']> {
584 return ({ onLinkPress: _press, pressableLinks: _links, ...props }) => Markdown(props)
585}
586
587/**
588 * The elements a transcript RESULT block draws with, on the surface it is drawn
589 * for. Its record keys press as the pane's do (Markdown with `onLinkPress`); off
590 * the terminal, where the plugin cannot open a browser itself, the surface opens
591 * the link.
592 */
593function transcriptKit(elements: ReturnType<EngineInterface['ui']['resolve']>, surface: RenderSurface): TranscriptKit {
594 const { Box, Text } = elements
595 const Markdown = 'Markdown' in elements ? (surface === 'terminal' ? elements.Markdown : surfaceOpened(elements.Markdown)) : undefined
596 return { Box, Text, ...(Markdown !== undefined && { Markdown }) }
597}
598
599/** One dim-labelled verdict line per text, its ⚑ or ✓ in the policy color. */
600function verdictLines(Box: Kit['Box'], Text: Kit['Text'], texts: string[]): RenderNode[] {
601 return texts.map(text => {
602 // Each part ` · ` apart; a part that opens with ⚑ or ✓ takes its policy color on the glyph (a write's change leads with ✎, so its policy part is not first).
603 const parts = text.split(/( · )/)
604 return (
605 <Box paddingLeft={2}>
606 <Text wrap="wrap">
607 <Text dimColor>{'⎿ GraphOS Inspector '}</Text>
608 {parts.map(part => {
609 const glyph = part.slice(0, 1)
610 const tone = glyph === '⚑' ? COLOR.deny : glyph === '✓' ? COLOR.allow : undefined
611 return tone === undefined ? <Text>{part}</Text> : <Text><Text color={tone}>{glyph}</Text>{part.slice(1)}</Text>
612 })}
613 </Text>
614 </Box>
615 )
616 })
617}
618
619/** The engine's row, then one dim-labelled verdict line per Agent Services call, its ⚑ or ✓ in the policy color. */
620function VerdictRows({ Box, Text, drawn, texts }: { Box: Kit['Box']; Text: Kit['Text']; drawn: RenderNode; texts: string[] }) {
621 return (
622 <Box flexDirection="column">
623 {drawn}
624 {verdictLines(Box, Text, texts)}
625 </Box>
626 )
627}
628
629// Why a draft did not land in the prompt box, in words the person can act on.
630const NO_COMPOSER = 'this session has no prompt box'
631const DIALOG_OPEN = 'close the open dialog first, then try again'
632const NOT_TAKEN = 'the prompt box did not take it'
633
634/**
635 * Appends a draft to the prompt box without submitting it or replacing existing
636 * text. Returns a failure reason or undefined on success; does not reject.
637 */
638async function fillPrompt($: EngineInterface, draft: string): Promise<string | undefined> {
639 try {
640 const box = await $.prompt.read()
641 // Repeated clicks must not duplicate the draft.
642 if (box.text.includes(draft)) return undefined
643 const { isFilled, refusal } = box.text.trim() === '' ? await $.prompt.fill({ text: draft }) : await $.prompt.fill({ text: `\n${draft}`, mode: 'append' })
644 return isFilled ? undefined : refusal === 'no_composer' ? NO_COMPOSER : DIALOG_OPEN
645 } catch {
646 return NOT_TAKEN
647 }
648}
649
650/**
651 * Drafts an access request for review. Submission requires a separate user action.
652 * Report failures in the transcript because another pane may be holding toasts.
653 */
654async function draftPrompt($: EngineInterface, draft: string, what = 'the access request') {
655 const why = await fillPrompt($, draft)
656 if (why !== undefined) $.ui.log(`GraphOS Inspector could not draft ${what}: ${why}.`)
657}
658
659async function isGasServer($: EngineInterface, server: string, options: { isFresh?: boolean } = {}): Promise<boolean> {
660 if (servers?.has(server)) return true
661 const now = await $.clock.now().catch(() => undefined)
662 const missedAt = misses.get(server)
663 if (options.isFresh !== true && now !== undefined && missedAt !== undefined && now - missedAt < SERVERS_TTL_MS) return false
664 listing ??= $.tool.list().then(gasServers).finally(() => (listing = undefined))
665 const found = await listing
666 servers = found
667 if (found.has(server)) misses.delete(server)
668 else if (now !== undefined) misses.set(server, now)
669 return found.has(server)
670}
671
672function inspectedCall(
673 e: { tool_use_id: string; operation?: unknown; variables?: unknown; agentId?: string },
674 server: string,
675 arrivedAt: number,
676 plugin?: string,
677): InspectedCall {
678 const adapted = adaptExecute(e)
679 // A subagent's call carries its agent: the id now (free), the label once the list answers (resolveAgent).
680 // A plugin's call carries the plugin's name, so it never reads as Claude's.
681 const agent = e.agentId === undefined && plugin === undefined ? undefined : { ...agentOf(e.agentId ?? ''), ...(plugin !== undefined && { plugin }) }
682 const base = { id: e.tool_use_id, server, status: 'pending' as const, owner: INSTANCE, arrivedAt, ...(agent !== undefined && { agent }) }
683 if (!adapted.ok) {
684 const ir = buildIR(e.tool_use_id, { ok: false, reason: 'unparseable', message: adapted.error })
685 return { ...base, operation: adapted.operation, variables: adapted.variables, inputError: adapted.error, ir }
686 }
687 const { operation, variables } = adapted.input
688 const shownVariables = Object.keys(variables).length === 0 ? '' : JSON.stringify(variables, null, 2)
689 return { ...base, operation, variables: shownVariables, ir: buildIR(e.tool_use_id, normalize(operation, variables)) }
690}
691
692/** `~/.claude/graphos-agent-mods/<name>`, from $HOME; undefined when HOME cannot be read. */
693async function userFile($: EngineInterface, name: string): Promise<string | undefined> {
694 try {
695 const home = (await $.process.run(['printenv', 'HOME'], { timeoutMs: 3000 })).stdout.trim()
696 return home.startsWith('/') ? `${home}/.claude/graphos-agent-mods/${name}` : undefined
697 } catch {
698 return undefined
699 }
700}
701
702const userLinksFile = ($: EngineInterface) => userFile($, 'links.toml')
703
704// Link mappings: the shipped links.toml plus the person's override file; loaded at session.start and on `/gas links`.
705let linkConfig: LinkConfig = configOf()
706// Where each named base in linkConfig comes from (src/links.ts BaseSource), as of the last load.
707let baseSources: Record<string, BaseSource> = {}
708let hasLoadedLinks = false
709
710// Sites learned from Agent Services responses (src/sites.ts), kept in $.store between sessions.
711// Each is checked again whenever the links load, so a stored value is never trusted for having been stored.
712const LEARNED_KEY = 'learnedSites'
713
714async function readLearned($: EngineInterface): Promise<Sites> {
715 try {
716 return learnedOf(await $.store.get(LEARNED_KEY))
717 } catch {
718 return {}
719 }
720}
721
722async function readText($: EngineInterface, path: string): Promise<string | undefined> {
723 try {
724 return await $.fs.read(path)
725 } catch {
726 return undefined
727 }
728}
729
730async function reloadLinks($: EngineInterface): Promise<{ file: string | undefined; problems: string[]; isUser: boolean }> {
731 const file = await userLinksFile($)
732 const user = file === undefined ? undefined : await readText($, file)
733 const shipped = await readText($, `${$.plugin.root}/links.toml`)
734 const loaded = loadLinkConfig({ ...(shipped !== undefined && { shipped }), ...(user !== undefined && { user }), learned: await readLearned($) })
735 linkConfig = loaded.config
736 baseSources = loaded.baseSources
737 hasLoadedLinks = true
738 return { file, problems: loaded.problems, isUser: user !== undefined }
739}
740
741// Trust rules: the person's own trust.graphql, read once per module instance
742// (at session.start, or a reloaded module's first Agent Services check) and on `/gas
743// trust`, never in between: a rule written into the file mid-session waits
744// for the person's next session or their own /gas trust. The mod does watch
745// the file (watchedFiles), but only to say it changed: a save is never read,
746// let alone applied, so nothing that can write the file mid-session widens
747// what runs unasked. No file, no rules: every call asks as it always did.
748let trustRules: TrustRule[] = []
749let trustLoad: Promise<TrustLoaded> | undefined
750// trust.graphql was saved since the rules in memory were read: shown in the
751// standing line until the person runs `/gas trust`.
752let isTrustFileChanged = false
753const isTrustOff = atom({ plugin: 'graphos-agent-mods', key: 'isTrustOff' } as const, false)
754
755type TrustLoaded = { file: string | undefined; problems: string[]; isPresent: boolean }
756
757async function reloadTrust($: EngineInterface): Promise<TrustLoaded> {
758 const file = await userFile($, 'trust.graphql')
759 const text = file === undefined ? undefined : await readText($, file)
760 const { rules, problems } = text === undefined ? { rules: [], problems: [] } : parseTrust(text)
761 trustRules = rules
762 return { file, problems, isPresent: text !== undefined }
763}
764
765/** The rules, read once per instance unless `/gas trust` reads them again. */
766function ensureTrust($: EngineInterface): Promise<TrustLoaded> {
767 trustLoad ??= reloadTrust($).catch(() => ({ file: undefined, problems: [], isPresent: false }))
768 return trustLoad
769}
770
771/** What a call's record keeps of its fit: bounded, since it lives in $.state. */
772function keptFit(fit: Fit): Fit {
773 if (!fit.isAllowed) return { isAllowed: false, reason: fit.reason.slice(0, 2_000) }
774 return { isAllowed: true, fits: fit.fits.slice(0, 50).map(one => ({ root: one.root.slice(0, 300), rule: one.rule.slice(0, 300), reasons: one.reasons.map(reason => reason.slice(0, 600)) })) }
775}
776
777// The standing line under the prompt (src/view/status.ts), pushed where the
778// state it counts changes (a call arriving or settling, trust rules loading,
779// switching or being reloaded) and never on a timer. Sent only when its text
780// changed, and one send after another, so an older line never lands over a newer.
781let shownStatus: string | undefined
782let statusChain: Promise<void> = Promise.resolve()
783
784/**
785 * What the caller has just written: a dispatch's `$` reads one moment, so it cannot read its own write back.
786 * `isReadingRules: false` keeps the line from reading trust.graphql for the rule count: reporting that the file
787 * was saved must never be what reads it.
788 */
789type KnownFacts = { tally?: { calls: number; unasked: number; bytes?: number }; isTrustOff?: boolean; isReadingRules?: boolean }
790// `/gas trust off`, as the last command left it: the atom is the record, this is what a long-lived dispatch's `$` cannot read back.
791let isTrustOffNow: boolean | undefined
792
793function showStatus($: EngineInterface, known: KnownFacts = {}): Promise<void> {
794 statusChain = statusChain
795 .then(async () => {
796 // A reloaded module has not read the rules yet: the line must not forget them.
797 if (known.isReadingRules !== false) await ensureTrust($)
798 const counted = known.tally ?? (await read($, tally))
799 const isOff = known.isTrustOff ?? isTrustOffNow ?? (await read($, isTrustOff))
800 const text = statusLine({ calls: counted.calls, unasked: counted.unasked, bytes: counted.bytes ?? 0, rules: trustRules.length, isTrustOff: isOff, isTrustFileChanged })
801 if (text === shownStatus) return
802 $.ui.status(text)
803 // Only once it was sent: a send that threw is tried again with the next change.
804 shownStatus = text
805 })
806 .catch(() => undefined)
807 return statusChain
808}
809
810/** One more Agent Services call this session; the line follows. Never rejects. */
811async function countCall($: EngineInterface) {
812 try {
813 await showStatus($, { tally: await update($, tally, counted => ({ ...counted, calls: counted.calls + 1 })) })
814 } catch {
815 // The line is a convenience.
816 }
817}
818
819/** A response Claude read, `bytes` of it (src/weight.ts); the line follows. Never rejects. */
820async function countRead($: EngineInterface, bytes: number) {
821 try {
822 await showStatus($, { tally: await update($, tally, counted => ({ ...counted, bytes: (counted.bytes ?? 0) + bytes })) })
823 } catch {
824 // The line is a convenience.
825 }
826}
827
828/** One more call that ran with no dialog because it fit a trust rule (its `trust.isAllowed`); the line follows. Never rejects. */
829async function countUnasked($: EngineInterface) {
830 try {
831 await showStatus($, { tally: await update($, tally, counted => ({ ...counted, unasked: counted.unasked + 1 })) })
832 } catch {
833 // The line is a convenience.
834 }
835}
836
837// The spinner's text while an Agent Services call runs. Only a call that runs with no
838// dialog is named here (the permission check answered allow: the engine's own
839// rule or a trust rule): the docs say nothing of the spinner while a
840// permission dialog is up, so a call that asks is left to the engine's own
841// word. The text is one small record, so a spinner row subscribes to it and to
842// no call's state. Begin, headline and end go one after another, in the order
843// they were asked, so an end can never land before the begin it follows.
844const NO_SPINNER = { id: '', text: '' }
845let spinnerChain: Promise<void> = Promise.resolve()
846// Calls whose spinner text has been ended, newest last and bounded: a begin that
847// is late (the call was aborted, or it settled, before the begin ran) must not set text nothing will clear.
848const endedSpinners = new Set<string>()
849const ENDED_SPINNERS_MAX = 200
850
851function inSpinnerOrder(work: () => Promise<unknown>) {
852 spinnerChain = spinnerChain.then(work).then(() => undefined, () => undefined)
853}
854
855/** The call `id` runs now, unasked: the spinner reads its headline, else `Agent Services · <operation name>`. */
856async function beginSpinner($: EngineInterface, server: string, id: string) {
857 if (!(await isGasServer($, server))) return
858 const call = findCall(await readCalls($), id)
859 if (call === undefined) return
860 const text = spinnerTextOf(call.ir)
861 // Decided as it is written: an end asked for before now wins.
862 await update($, spinner, current => (endedSpinners.has(id) ? current : { id, text }))
863}
864
865/** The headline of call `id` has landed: the spinner reads it, if the call is still the one it names. */
866function headlineSpinner($: EngineInterface, id: string, ir: CallIR) {
867 const text = spinnerTextOf(ir)
868 return update($, spinner, current => (current.id === id ? { id, text } : current))
869}
870
871/** Call `id` is over, or gone: the spinner goes back to the engine's own word, unless it already names another call. Asked for in order, and remembered so a later begin is ignored. */
872function endSpinner($: EngineInterface, id: string) {
873 endedSpinners.add(id)
874 const oldest = endedSpinners.size > ENDED_SPINNERS_MAX ? endedSpinners.values().next().value : undefined
875 if (oldest !== undefined) endedSpinners.delete(oldest)
876 inSpinnerOrder(() => update($, spinner, current => (current.id === id ? NO_SPINNER : current)))
877}
878
879let unameText: Promise<string> | undefined
880
881/**
882 * Opens a link in the system browser: https on a configured host only. A
883 * link it will not or cannot open gets one transcript line saying so.
884 */
885async function openLink($: EngineInterface, url: unknown) {
886 let plan: ReturnType<typeof openArgv> | undefined
887 try {
888 unameText ??= $.process.run(['uname', '-s'], { timeoutMs: OPEN_TIMEOUT_MS }).then(ran => ran.stdout)
889 // The auth links Agent Services returned for the calls in the pane: the one press that may open a host no rule names.
890 const calls = await readCalls($)
891 const fromGas = [...calls.queue, ...(calls.last === null ? [] : [calls.last]), ...calls.history].flatMap(call => (call.outcome?.authLinks ?? []).map(link => link.url))
892 plan = openArgv(url, linkConfig, await unameText, fromGas)
893 if ('refused' in plan) {
894 $.ui.log(plan.refused === 'no opener for this platform' ? 'GraphOS Inspector cannot open a browser on this system.' : 'GraphOS Inspector did not open that link: its host is not one your link settings name (/gas links).')
895 return
896 }
897 const ran = await $.process.run(plan.argv, { timeoutMs: OPEN_TIMEOUT_MS })
898 if (ran.exitCode !== 0) $.ui.log(`GraphOS Inspector could not open ${plan.argv.at(-1)}`)
899 } catch {
900 unameText = undefined
901 // Only a link the checks passed is said in full.
902 $.ui.log(plan !== undefined && 'argv' in plan ? `GraphOS Inspector could not open ${plan.argv.at(-1)}` : 'GraphOS Inspector could not open that link.')
903 }
904}
905
906/**
907 * Says which subagent made a call, by what `$.agent.list()` knows of it (its
908 * type and task). Runs beside the dialog, never ahead of it, and gives up
909 * after a few seconds; the call keeps the bare `from subagent` when the list
910 * does not know the agent. Best effort, never rejects.
911 */
912async function resolveAgent($: EngineInterface, callId: string, agentId: string, plugin: string | undefined) {
913 try {
914 const info = (await withinStep($, 'the agent list', $.agent.list(), AGENT_LIST_MS)).find(one => one.id === agentId)
915 if (info === undefined) return
916 const agent = { ...agentOf(agentId, info), ...(plugin !== undefined && { plugin }) }
917 await updateCalls($, state => patchAgent(state, callId, agent))
918 void snap($, callId, 'agent')
919 } catch {
920 // The line says `from subagent` and no more.
921 }
922}
923
924/** How long the pane waits for the agent list. */
925const AGENT_LIST_MS = 5_000
926
927/**
928 * The pane's side of a call arriving: back to live (CLOSED has cursor: null),
929 * the raw pane retitled, and the pane opened once unasked. Runs beside the
930 * permission dialog, never ahead of it; best effort, never rejects.
931 */
932async function showArrival($: EngineInterface) {
933 try {
934 await update($, paneUi, () => CLOSED)
935 await retitleRaw($)
936 // Unasked, the engine seats a pane only where it can dock (144 columns).
937 if (!(await read($, hasAutoOpened))) {
938 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS })
939 if (opened.isPlaced) await update($, hasAutoOpened, () => true)
940 }
941 } catch {
942 // No pane host here (a test engine, `-p`); /gas still works where there is one.
943 }
944}
945
946/** The Claude Code release this session runs on (`$.session.version()`); undefined where the engine has no such call (the mod loads from 2.1.287). */
947async function claudeVersion($: EngineInterface): Promise<{ version: string; base?: string } | undefined> {
948 try {
949 const { version, base } = await $.session.version()
950 return { version, ...(base !== undefined && { base }) }
951 } catch {
952 return undefined
953 }
954}
955
956// Said once per module instance: a session that starts again (a resume, a /clear that starts one) does not repeat it.
957let hasNotedVersion = false
958
959/** One transcript line when the engine is older than the release this was tested on (src/version.ts). */
960async function noteVersion($: EngineInterface) {
961 if (hasNotedVersion) return
962 hasNotedVersion = true
963 const note = versionNote((await claudeVersion($))?.base, 'GraphOS Agent Mods')
964 if (note !== undefined) $.ui.log(`GraphOS Inspector: ${note}`)
965}
966
967/** Tool availability after applying the permission decision and organization ceiling. */
968async function toolState($: EngineInterface, tool: string): Promise<ToolState> {
969 try {
970 const { decision, ceiling } = await $.tool.check({ tool, input: {} })
971 if (decision === 'deny') return 'deny'
972 if (decision !== 'allow') return 'ask'
973 return ceiling !== undefined && ceiling !== 'allow' ? 'capped' : 'allow'
974 } catch {
975 return 'ask'
976 }
977}
978
979/** How long `/gas setup` waits for the graph's catalog. */
980const SETUP_SEARCH_MS = 8_000
981
982/**
983 * Collect setup requirements and pass them to src/setup.ts for display.
984 * Catalog discovery uses an allowed search call. Site discovery is drafted
985 * into the prompt box for the user to review and submit.
986 */
987async function setupText($: EngineInterface): Promise<string> {
988 const version = await claudeVersion($)
989 let servers: string[] | undefined
990 try {
991 servers = [...gasServers(await $.tool.list())]
992 } catch {
993 servers = undefined
994 }
995 const tools: { server: string; tool: string; state: ToolState }[] = []
996 for (const server of servers ?? []) for (const tool of READ_ONLY_GAS_TOOLS) tools.push({ server, tool, state: await toolState($, `mcp__${server}__${tool}`) })
997 const { file } = await reloadLinks($)
998 // Which sites the graph has a service for, from its catalog (one search, only where it is allowed): a graph with no Jira or Slack is not asked about them.
999 const first = servers?.[0]
1000 const unset = learnable(baseSources)
1001 const scopes = first === undefined || unset.length === 0 ? undefined : await withinStep($, 'Agent Services', scopesOf(gasCaller($, first), cacheForServer(caches, first)), SETUP_SEARCH_MS).catch(() => undefined)
1002 // An empty catalog leaves the available services unknown.
1003 const graph = scopes === undefined || scopes.length === 0 ? undefined : sitesOfGraph(scopes)
1004 const asked = graph === undefined ? [] : unset.filter(site => graph.shown.includes(site) && graph.askable.includes(site))
1005 const isAsking = asked.length > 0
1006 const why = isAsking ? await fillPrompt($, setupPrompt(asked)) : undefined
1007 await ensureTrust($)
1008 const trustFile = await userFile($, 'trust.graphql')
1009 return setupReport({
1010 ...(version !== undefined && { version }),
1011 ...(servers !== undefined && { servers }),
1012 tools,
1013 bases: linkConfig.bases,
1014 sources: baseSources,
1015 ...(file !== undefined && { linksFile: file }),
1016 ...(graph !== undefined && { graph }),
1017 ...(isAsking && { draft: why === undefined ? { isDrafted: true as const } : { isDrafted: false as const, why } }),
1018 trust: { count: trustRules.length, isOff: await read($, isTrustOff), isFileChanged: isTrustFileChanged, ...(trustFile !== undefined && { file: trustFile }), example: `${$.plugin.root}/trust.example.graphql` },
1019 })
1020}
1021
1022async function ensureLinks($: EngineInterface) {
1023 if (!hasLoadedLinks) await reloadLinks($).catch(() => undefined)
1024 hasLoadedLinks = true
1025}
1026
1027let learning: Promise<void> = Promise.resolve()
1028
1029/**
1030 * Learns the Atlassian site or Slack workspace an Agent Services response shows, when
1031 * that base is still unset (src/sites.ts says what counts). One at a time, so
1032 * two responses landing together tell the person once. Called with a response
1033 * BEFORE its record links are worked out (outcomeOf), so its own rows open on
1034 * the site it taught. Best effort: never rejects, and a response that teaches
1035 * nothing costs one look at which bases are unset.
1036 */
1037function learnSites($: EngineInterface, ir: CallIR, result: unknown): Promise<void> {
1038 learning = learning.then(() => learnFrom($, ir, result)).catch(() => undefined)
1039 return learning
1040}
1041async function learnFrom($: EngineInterface, ir: CallIR, result: unknown) {
1042 // The cheap gate: nothing is parsed unless a base a response can teach is still unset.
1043 const wanted = learnable(baseSources)
1044 if (wanted.length === 0) return
1045 const found = sitesIn(ir, responseIn(result))
1046 const fresh = wanted.filter(site => found[site] !== undefined)
1047 if (fresh.length === 0) return
1048 const before = linkConfig.bases
1049 await $.store.set(LEARNED_KEY, { ...(await readLearned($)), ...Object.fromEntries(fresh.map(site => [site, found[site]])) })
1050 await reloadLinks($)
1051 const hosts = fresh.filter(site => linkConfig.bases[site] !== before[site]).map(site => escapeText(hostOf(linkConfig.bases[site] ?? ''), 100).text)
1052 if (hosts.length > 0) $.ui.log(`GraphOS Inspector: record links now open on ${hosts.join(' and ')}, learned from an Agent Services response. /gas links says where each site comes from.`)
1053}
1054
1055// The person's two config files, absolute, by the same HOME lookup as every
1056// user file. The engine's file watcher takes absolute paths (they need not be
1057// in the project) from a SessionStart answer's `watchPaths`, and raises
1058// FileChanged for them. Not kept when HOME could not be read: asked again.
1059let watched: { links: string; trust: string } | undefined
1060
1061async function watchedFiles($: EngineInterface): Promise<{ links: string; trust: string } | undefined> {
1062 if (watched !== undefined) return watched
1063 const links = await userFile($, 'links.toml')
1064 const trust = await userFile($, 'trust.graphql')
1065 if (links === undefined || trust === undefined) return undefined
1066 watched = { links, trust }
1067 return watched
1068}
1069
1070/** How long a burst of saves of links.toml settles before it is read: an editor's atomic write is an unlink and an add, and one reload and one line follow it. */
1071const LINKS_SETTLE_MS = 300
1072let linksTimer: Timer | undefined
1073
1074/** links.toml was saved, added or removed: read again shortly. */
1075function linksSoon($: EngineInterface) {
1076 linksTimer?.cancel()
1077 linksTimer = $.clock.after(LINKS_SETTLE_MS, () => void linksSaved($))
1078}
1079
1080/** Reads links.toml again and tells the person in one quiet line. A link mapping only decides where a record opens, so a save applies at once. */
1081async function linksSaved($: EngineInterface) {
1082 try {
1083 // The hosts a click may open are what a save can widen: say which are new (not knowable in an instance that has not loaded the links yet).
1084 const before = hasLoadedLinks ? allowedOrigins(linkConfig) : undefined
1085 const { problems, isUser } = await reloadLinks($)
1086 const rows = `${linkConfig.records.length} row rule${linkConfig.records.length === 1 ? '' : 's'}`
1087 const added = before === undefined ? [] : [...allowedOrigins(linkConfig)].filter(origin => !before.has(origin)).map(origin => escapeText(new URL(origin).host, 100).text)
1088 const hosts = added.length === 0 ? '' : `; links now open on ${added.slice(0, 5).join(', ')}${added.length > 5 ? ` and ${added.length - 5} more` : ''}`
1089 $.ui.log(isUser ? `GraphOS Inspector: links.toml reloaded: ${rows}${problems.length === 0 ? '' : `, ${problems.length} skipped`}${hosts}` : `GraphOS Inspector: links.toml removed, the shipped link mappings stand (${rows})${hosts}`)
1090 } catch {
1091 $.ui.log('GraphOS Inspector: links.toml changed, but it could not be read again.')
1092 }
1093}
1094
1095/**
1096 * trust.graphql was saved. It is not read: the rules in memory stand until the
1097 * person runs `/gas trust`, so a rule an agent writes into it mid-session
1098 * cannot take effect on its own. The person is told once per change (the
1099 * standing line keeps saying it), and how to apply it.
1100 */
1101function trustSaved($: EngineInterface) {
1102 const isNew = !isTrustFileChanged
1103 isTrustFileChanged = true
1104 // Said without reading the file: this instance may not have read it yet, and a save is not what loads it.
1105 void showStatus($, { isReadingRules: false })
1106 if (isNew) $.ui.log('GraphOS Inspector: trust.graphql changed. Run /gas trust to reload it; until then, the rules loaded earlier stand.')
1107}
1108
1109export const register: Register = (on, options) => {
1110 // Start with shipped links; session setup loads user mappings and learned sites.
1111 linkConfig = configOf()
1112 hasLoadedLinks = false
1113 snapshots = snapshotOptionsOf(options)
1114 on('session.start', async ($, e, next) => {
1115 // Arrivals start the pump on this session's `$`, as it always ran: its dispatch is over, so its reads see every write.
1116 wakePump = () => startPump($)
1117 // One round picks up what a reload left analysing.
1118 wakePump()
1119 await ensureLinks($)
1120 await ensureTrust($)
1121 void showStatus($)
1122 void noteVersion($).catch(() => undefined)
1123 try {
1124 // Immediate: the command only reads and writes the pane, so typed mid-turn it need not wait for the turn to end.
1125 await $.command.register({ name: 'gas', description: 'Show the GraphOS Inspector pane for the current Agent Services call', argumentHint: '[setup|older|newer|live|raw|trust|links]', immediate: true })
1126 } catch {
1127 // No command host (a test engine); the pane still opens on its own.
1128 }
1129 return next(e)
1130 })
1131
1132 // Watch the person's config files for a save: the engine's watcher takes
1133 // absolute paths outside the project (2.1.292), and raises FileChanged for them.
1134 on('classic.SessionStart', async ($, e, next) => {
1135 const result = await next(e)
1136 // Watching is a convenience: a HOME lookup that fails leaves the session's own answer as it was.
1137 const files = await watchedFiles($).catch(() => undefined)
1138 return files === undefined ? result : { ...result, watchPaths: [...(result.watchPaths ?? []), files.links, files.trust] }
1139 })
1140
1141 on('classic.FileChanged', { file_path: /\/\.claude\/graphos-agent-mods\/(links\.toml|trust\.graphql)$/ }, async ($, e, next) => {
1142 const result = await next(e)
1143 try {
1144 const files = await watchedFiles($)
1145 if (e.file_path === files?.links) linksSoon($)
1146 else if (e.file_path === files?.trust) trustSaved($)
1147 } catch {
1148 // A hook that throws is skipped; the files are watched again at the next session.
1149 }
1150 return result
1151 })
1152
1153 on('command.run', { command: 'gas' }, async ($, e) => {
1154 // `/gas older|newer|live` steps the pane through past calls, as its buttons do.
1155 const step = e.args.trim()
1156 if (step === 'snapshots on' || step === 'snapshots off') {
1157 await $.store.set(SNAPSHOTS_KEY, step === 'snapshots on')
1158 return { text: `Automatic snapshots ${step === 'snapshots on' ? 'on' : 'off'}: each stage of every Agent Services call is written under ${$.plugin.root}/snapshots/.` }
1159 }
1160 if (step === 'setup') return { text: await setupText($) }
1161 if (step === 'links forget') {
1162 const had = Object.entries(await readLearned($))
1163 try {
1164 await $.store.delete(LEARNED_KEY)
1165 } catch {
1166 return { text: 'Could not clear the learned sites: the plugin store could not be written.' }
1167 }
1168 await reloadLinks($)
1169 const forgot = had.map(([site, base]) => `${site} (${escapeText(hostOf(base), 100).text})`)
1170 return { text: forgot.length === 0 ? 'No learned sites to forget.' : `Forgot the learned sites: ${forgot.join(', ')}. Record links to them are off until an Agent Services response shows them again; /gas links says where each site comes from.` }
1171 }
1172 if (step === 'links') {
1173 const { file, problems, isUser } = await reloadLinks($)
1174 const where = file === undefined ? 'no override location (HOME is unknown)' : `${file} (${isUser ? 'loaded' : 'not found: create it to add mappings'})`
1175 const notes = problems.length === 0 ? '' : `\nSkipped: ${problems.join('; ')}`
1176 const sites = describeBases(linkConfig.bases, baseSources, file).join('\n')
1177 return { text: `Link mappings reloaded: ${linkConfig.records.length} row rules, ${linkConfig.searches.length} search links.\nSites record links open on:\n${sites}\nShipped defaults: ${$.plugin.root}/links.toml\nYour overrides: ${where}${notes}` }
1178 }
1179 if (step === 'trust' || step === 'trust on' || step === 'trust off') {
1180 const isOff = step === 'trust' ? await read($, isTrustOff) : await update($, isTrustOff, () => step === 'trust off')
1181 isTrustOffNow = isOff
1182 if (step === 'trust off') {
1183 void showStatus($, { isTrustOff: true })
1184 return { text: 'Trust rules off for this session: every Agent Services call asks. /gas trust on turns them back on.' }
1185 }
1186 // What is on disk is what is loaded now: cleared before the read, so a save that lands during it is not lost.
1187 isTrustFileChanged = false
1188 trustLoad = reloadTrust($)
1189 const { file, problems, isPresent } = await trustLoad
1190 void showStatus($, { isTrustOff: isOff })
1191 const where = file === undefined ? 'no rules file location (HOME is unknown)' : isPresent ? file : `${file} (not found: create it to add rules; see trust.example.graphql in ${$.plugin.root})`
1192 const listed = trustRules.length === 0 ? 'No rules: every Agent Services call asks.' : `${trustRules.length} rule${trustRules.length === 1 ? '' : 's'}: an Agent Services query that fits one runs without asking.\n${describeRules(trustRules).map(line => ` ${line}`).join('\n')}`
1193 const notes = problems.length === 0 ? '' : `\nSkipped: ${problems.join('; ')}`
1194 const state = isOff ? '\nOff for this session (/gas trust on).' : ''
1195 return { text: `Trust rules reloaded from ${where}.\n${listed}${notes}${state}\nPatterns match the text, not what it means; mutations and change-named roots always ask.` }
1196 }
1197 if (step === 'timing on' || step === 'timing off') {
1198 await $.store.set(TIMING_KEY, step === 'timing on')
1199 return { text: `Summary timing ${step === 'timing on' ? 'on' : 'off'}: the last ${TIMING_KEEP} summaries go to ${$.plugin.root}/timing.log.` }
1200 }src/adapter.ts 55 lines1// Agent Services execute input → { operation, variables }. Pure: no $.
2//
3// `variables` arrives as an object or as a JSON-encoded string (the
4// apollo-mcp-server contract). Anything we cannot read is reported, not
5// guessed at, so the pane can show the raw input instead.
6
7export type ExecuteInput = {
8 operation: string
9 variables: Record<string, unknown>
10}
11
12export type Adapted =
13 | { ok: true; input: ExecuteInput }
14 | { ok: false; error: string; operation: string; variables: string }
15
16const isPlainObject = (value: unknown): value is Record<string, unknown> =>
17 typeof value === 'object' && value !== null && !Array.isArray(value)
18
19const show = (value: unknown): string => {
20 if (typeof value === 'string') return value
21 try {
22 return JSON.stringify(value, null, 2) ?? String(value)
23 } catch {
24 return String(value)
25 }
26}
27
28export function adaptExecute(raw: { operation?: unknown; variables?: unknown }): Adapted {
29 const { operation, variables } = raw
30 const fail = (error: string): Adapted => ({
31 ok: false,
32 error,
33 operation: typeof operation === 'string' ? operation : show(operation),
34 variables: variables === undefined ? '' : show(variables),
35 })
36
37 if (typeof operation !== 'string') return fail('operation is not a string')
38 if (variables === undefined || variables === null) return { ok: true, input: { operation, variables: {} } }
39
40 if (typeof variables === 'string') {
41 if (variables.trim() === '') return { ok: true, input: { operation, variables: {} } }
42 let parsed: unknown
43 try {
44 parsed = JSON.parse(variables)
45 } catch {
46 return fail('variables is a string that is not valid JSON')
47 }
48 if (!isPlainObject(parsed)) return fail('variables is not a JSON object')
49 return { ok: true, input: { operation, variables: parsed } }
50 }
51
52 if (!isPlainObject(variables)) return fail('variables is not an object')
53 return { ok: true, input: { operation, variables } }
54}
55src/escape.ts 40 lines1// Make model- and schema-written strings safe to draw. Pure: no $.
2//
3// Render terminal sequences, C0/C1 controls, bidi overrides and invisible
4// characters as visible escapes. Input text cannot change terminal colors,
5// move the cursor or alter the displayed text order.
6
7// An unterminated OSC stops at the line's end.
8const SEQUENCE = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b\n]*(?:\x07|\x1b\\)?|\x1b[@-_]?/g
9const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
10// Bidi overrides and isolates, zero-width characters, line separators.
11const INVISIBLE_RANGES: [number, number][] = [
12 [0x061c, 0x061c],
13 [0x200b, 0x200f],
14 [0x2028, 0x2029],
15 [0x202a, 0x202e],
16 [0x2060, 0x2060],
17 [0x2066, 0x2069],
18 [0xfeff, 0xfeff],
19]
20// Built from code points so the source itself holds no invisible characters.
21const point = (code: number) => `\\u{${code.toString(16)}}`
22const INVISIBLE = new RegExp(`[${INVISIBLE_RANGES.map(([from, to]) => `${point(from)}-${point(to)}`).join('')}]`, 'gu')
23
24const hex = (char: string, width: number) => char.codePointAt(0)!.toString(16).padStart(width, '0')
25
26export type Escaped = { text: string; isTruncated: boolean }
27
28/** Escapes `value` and caps it at `max` characters (10,000 is a `Text` child's limit). */
29export function escapeText(value: string, max = 9_000): Escaped {
30 const text = value
31 .replace(SEQUENCE, match => `\\x1b${JSON.stringify(match.slice(1)).slice(1, -1)}`)
32 .replace(CONTROL, char => `\\x${hex(char, 2)}`)
33 .replace(INVISIBLE, char => `\\u{${hex(char, 4)}}`)
34 .replace(/\t/g, ' ')
35 if (text.length <= max) return { text, isTruncated: false }
36 // Never cut a surrogate pair in half.
37 const isHighSurrogate = /[\ud800-\udbff]/.test(text.charAt(max - 1))
38 return { text: `${text.slice(0, isHighSurrogate ? max - 1 : max)}…`, isTruncated: true }
39}
40src/annotate.ts 198 lines1// Folds what enrichment learned into the IR. Pure: no $.
2//
3// Each source is optional: whatever is missing leaves its facts unknown and
4// the call `partial`, never assumed. Policy is looked up by dry_run path
5// (response keys, so aliases), schema by the real name under its parent type.
6
7import type { Access } from './gas.ts'
8import type { ArgIR, CallIR, CallState, Checks, FieldIR, OmittedArg, OpType, Paging, Validation } from './ir.ts'
9import { enumsOf, hintsFrom, scopesFrom } from './schema.ts'
10import type { FieldDef, SchemaIndex } from './schema.ts'
11
12const ROOT_TYPE: Record<OpType, string> = { query: 'Query', mutation: 'Mutation', subscription: 'Subscription' }
13
14export type Enrichment = {
15 schema?: SchemaIndex
16 access?: Access
17 validation?: Validation
18 /** The service scope the call was checked against. */
19 scope?: string
20 /** Every service the call touches, in the order its roots appear. */
21 services?: string[]
22 /** The scope each root was checked against, by root path. */
23 rootScopes?: ReadonlyMap<string, string>
24 bundleDigest?: string
25 /** True when a source failed or was skipped. */
26 isIncomplete: boolean
27 checks?: Checks
28}
29
30const ARG_KINDS: [Paging['kind'], string[]][] = [
31 ['token', ['pageToken', 'nextPageToken']],
32 ['cursor', ['cursor', 'after', 'before']],
33 ['offset', ['startAt', 'offset', 'start']],
34 ['page', ['page']],
35]
36// Fields that hold the continuation, then flags that say whether more remains.
37const TOKEN_FIELDS = ['nextPageToken', 'nextCursor', 'next', 'cursor', 'pageToken', 'after', 'endCursor']
38const FLAG_FIELDS = ['hasMoreResults', 'hasNextPage', 'isLast', 'pageInfo']
39
40const PAGINATION_FIELDS = ['pagination', 'pageInfo']
41
42/** A paging argument's value that still asks for the first page: no token or cursor, an offset of 0, page 0 or 1. */
43function isFirstValue(kind: Paging['kind'], value: unknown): boolean {
44 if (value === null || value === undefined || value === '') return true
45 const number = typeof value === 'number' ? value : typeof value === 'string' && /^\d{1,15}$/.test(value) ? Number(value) : undefined
46 if (number === undefined) return false
47 return kind === 'offset' ? number === 0 : kind === 'page' && number <= 1
48}
49
50/** `page`-style paging: a `pagination` / `pageInfo` object with `page` and `pageCount`, on the response or one object down. */
51function pageCountOf(response: Map<string, FieldDef> | undefined, schema: SchemaIndex): string | undefined {
52 const inspect = (fields: Map<string, FieldDef> | undefined): string | undefined => {
53 for (const name of PAGINATION_FIELDS) {
54 const info = schema.get(fields?.get(name)?.namedType ?? '')
55 if (info?.has('page') && info.has('pageCount') && info.has('totalCount')) return 'pageCount'
56 }
57 return undefined
58 }
59 const own = inspect(response)
60 if (own !== undefined) return own
61 for (const one of response?.values() ?? []) if (!one.isList) { const found = inspect(schema.get(one.namedType)); if (found !== undefined) return found }
62 return undefined
63}
64
65function pagingOf(field: FieldIR, def: FieldDef, schema: SchemaIndex): Paging | undefined {
66 const response = schema.get(def.namedType)
67 const isListing =
68 def.isList ||
69 [...(response?.values() ?? [])].some(one => one.isList || [...(schema.get(one.namedType)?.values() ?? [])].some(inner => inner.isList))
70 if (!isListing) return undefined
71 const declared = Object.keys(def.argDefs)
72 const isPagingArg = (name: string) => ARG_KINDS.some(([, names]) => names.includes(name))
73 const kindOf = (name: string) => ARG_KINDS.find(([, names]) => names.includes(name))?.[0] ?? 'token'
74 for (const [kind, names] of ARG_KINDS) {
75 const via = declared.filter(name => names.includes(name))
76 if (via.length === 0) continue
77 // An argument set to the value that means the beginning (`startAt: 0`, `nextPageToken: null`, `page: 1`) is still the first page.
78 const moved = field.args.filter(arg => declared.includes(arg.name) && isPagingArg(arg.name) && !isFirstValue(kindOf(arg.name), arg.value))
79 const moreField = TOKEN_FIELDS.find(name => response?.has(name)) ?? FLAG_FIELDS.find(name => response?.has(name))
80 const flagField = FLAG_FIELDS.find(name => response?.has(name))
81 const pageCountField = kind === 'page' ? pageCountOf(response, schema) : undefined
82 return {
83 kind,
84 via,
85 isFirstPage: moved.length === 0,
86 ...(pageCountField !== undefined && { pageCountField }),
87 ...(moreField !== undefined && { moreField }),
88 ...(flagField !== undefined && flagField !== moreField && { flagField }),
89 }
90 }
91 return undefined
92}
93
94function annotateArg(arg: ArgIR, def: FieldDef | undefined, schema: SchemaIndex | undefined): ArgIR {
95 const known = def?.argDefs[arg.name]
96 if (known === undefined) return arg
97 const enumValues = schema === undefined ? undefined : enumsOf(schema).get(known.namedType)
98 // A default belongs to an argument the call left out; this one was set.
99 const hints = hintsFrom(known.description).filter(hint => !/^default\b/i.test(hint))
100 return {
101 ...arg,
102 type: known.type,
103 ...(known.defaultValue !== undefined && { defaultValue: known.defaultValue }),
104 ...(known.description !== undefined && { description: known.description }),
105 ...(enumValues !== undefined && { enumValues }),
106 ...(hints.length > 0 && { hints }),
107 }
108}
109
110function omittedOf(field: FieldIR, def: FieldDef): OmittedArg[] {
111 const set = new Set(field.args.map(arg => arg.name))
112 return Object.values(def.argDefs)
113 .filter(arg => !set.has(arg.name))
114 .map(arg => ({
115 name: arg.name,
116 type: arg.type,
117 ...(arg.defaultValue !== undefined && { default: arg.defaultValue }),
118 ...(arg.description !== undefined && { description: arg.description }),
119 isRequired: arg.type.endsWith('!') && arg.defaultValue === undefined,
120 }))
121}
122
123function annotateField(field: FieldIR, parentType: string | undefined, found: Enrichment, isRoot = false): FieldIR {
124 const owner = field.onType ?? parentType
125 // A type condition equal to the parent type names nothing new: not a branch.
126 const { onType: _same, ...bare } = field
127 const base: FieldIR = field.onType !== undefined && field.onType === parentType ? bare : field
128 const def = owner === undefined ? undefined : found.schema?.get(owner)?.get(field.name)
129 const decision = found.access?.fields.get(field.path)
130 const scopes = scopesFrom(def?.description)
131 const hints = hintsFrom(def?.description)
132 const paging = isRoot && def !== undefined && found.schema !== undefined ? pagingOf(field, def, found.schema) : undefined
133 const service = isRoot ? found.rootScopes?.get(field.path) : undefined
134 return {
135 ...base,
136 ...(service !== undefined && { service }),
137 coordinate: owner === undefined ? field.name : `${owner}.${field.name}`,
138 args: field.args.map(arg => annotateArg(arg, def, found.schema)),
139 ...(def !== undefined && Object.keys(def.argDefs).length > 0 && { omittedArgs: omittedOf(field, def) }),
140 ...(paging !== undefined && { paging }),
141 ...(def !== undefined && {
142 schema: {
143 type: def.type,
144 isList: def.isList,
145 isNonNull: def.isNonNull,
146 isListItemNonNull: def.isListItemNonNull,
147 isListNonNull: def.isListNonNull,
148 ...(hints.length > 0 && { hints }),
149 ...(def.description !== undefined && { description: def.description }),
150 ...(def.deprecated !== undefined && { deprecated: def.deprecated }),
151 scopes,
152 tags: def.tags,
153 ...(def.requiresInclude !== undefined && { requiresInclude: def.requiresInclude }),
154 ...(def.isOpaque === true && { isOpaque: true }),
155 },
156 }),
157 policy: decision?.decision ?? 'unknown',
158 ...(decision?.denialContext !== undefined && { denialContext: decision.denialContext }),
159 children: field.children.map(child => annotateField(child, def?.namedType, found)),
160 }
161}
162
163export function annotate(ir: CallIR, found: Enrichment): CallIR {
164 if (ir.state === 'unparseable' || ir.opType === undefined) return ir
165 const rootType = ROOT_TYPE[ir.opType]
166 const state: CallState =
167 found.validation?.valid === false ? 'invalid' : found.isIncomplete ? 'partial' : 'ready'
168 return {
169 ...ir,
170 ...(found.scope !== undefined && { service: found.scope }),
171 ...(found.services !== undefined && { services: found.services }),
172 ...(found.bundleDigest !== undefined && { bundleDigest: found.bundleDigest }),
173 ...(found.validation !== undefined && { validation: found.validation }),
174 ...(found.checks !== undefined && { checks: found.checks }),
175 ...(found.access !== undefined && { isOperationDenied: found.access.denyOperation }),
176 state,
177 roots: ir.roots.map(root => annotateField(root, rootType, found, true)),
178 }
179}
180
181/**
182 * The fields policy counts: data-bearing leaves (no children), and a denied or
183 * masked object as one field, its selection left uncounted since none of it
184 * comes back to be read. Selection order.
185 */
186export const isPolicyUnit = (field: FieldIR): boolean => field.children.length === 0 || field.policy === 'deny' || field.policy === 'mask'
187
188export function policyUnits(fields: readonly FieldIR[]): FieldIR[] {
189 return fields.flatMap(field => (isPolicyUnit(field) ? [field] : policyUnits(field.children)))
190}
191
192/** Policy counts over `policyUnits`: what a reader can see, and a restricted object once. */
193export function leafPolicyCounts(fields: readonly FieldIR[]): { allow: number; mask: number; deny: number; unknown: number } {
194 const counts = { allow: 0, mask: 0, deny: 0, unknown: 0 }
195 for (const field of policyUnits(fields)) counts[field.policy] += 1
196 return counts
197}
198src/build.ts 59 lines1// Normalized operation → CallIR, before any enrichment. Pure: no $.
2//
3// Coordinates start as `Query.field` for roots and bare names below; the
4// enrichment step fills parent types, schema facts and policy in place.
5
6import type { CallIR, FieldIR, OpType } from './ir.ts'
7import type { NormalizedField, Normalized } from './normalize.ts'
8
9const ROOT_TYPE: Record<OpType, string> = { query: 'Query', mutation: 'Mutation', subscription: 'Subscription' }
10
11function fieldIR(field: NormalizedField, parentPath: string, parentType: string | undefined): FieldIR {
12 const key = field.alias ?? field.name
13 const path = parentPath === '' ? key : `${parentPath}.${key}`
14 const owner = field.onType ?? parentType
15 return {
16 name: field.name,
17 ...(field.alias !== undefined && { alias: field.alias }),
18 ...(field.onType !== undefined && { onType: field.onType }),
19 coordinate: owner === undefined ? field.name : `${owner}.${field.name}`,
20 path,
21 args: field.args.map(arg => ({ name: arg.name, value: arg.value, fromVariable: arg.fromVariable })),
22 policy: 'unknown',
23 children: field.children.map(child => fieldIR(child, path, undefined)),
24 }
25}
26
27/** The service prefix of a root field (`confluence_search` → `confluence`), until scopes resolve it. */
28export function provisionalService(rootField: string): string | undefined {
29 const cut = rootField.indexOf('_')
30 return cut > 0 ? rootField.slice(0, cut) : undefined
31}
32
33export function buildIR(toolCallId: string, normalized: Normalized): CallIR {
34 if (!normalized.ok) {
35 return { toolCallId, state: 'unparseable', failure: `${normalized.reason}: ${normalized.message}`, roots: [] }
36 }
37 const roots = normalized.roots.map(root => fieldIR(root, '', ROOT_TYPE[normalized.opType]))
38 const first = roots[0]
39 const service = first === undefined ? undefined : provisionalService(first.name)
40 const services = [...new Set(roots.flatMap(root => provisionalService(root.name) ?? []))]
41 return {
42 toolCallId,
43 ...(service !== undefined && { service }),
44 ...(services.length > 0 && { services }),
45 opType: normalized.opType,
46 ...(normalized.opName !== undefined && { opName: normalized.opName }),
47 state: 'analyzing',
48 roots,
49 printed: normalized.printed,
50 }
51}
52
53/** Deepest selection depth below the roots (a root with scalar children is 1). */
54export function depthOf(fields: readonly FieldIR[]): number {
55 let deepest = 0
56 for (const field of fields) deepest = Math.max(deepest, field.children.length === 0 ? 0 : 1 + depthOf(field.children))
57 return deepest
58}
59src/enrich.ts 305 lines1// Enrichment: validate, dry_run and schema lookups for one call, in parallel.
2// Pure apart from the `call` handed in (register.tsx passes $.mcp.call), so
3// it is tested in plain Node with canned Agent Services answers.
4//
5// Only read-only Agent Services tools are ever called: search, introspect, validate,
6// dry_run. Every failure leaves its facts unknown; nothing is assumed.
7
8import type { McpToolResult } from 'claude-code'
9
10import { annotate } from './annotate.ts'
11import type { Enrichment } from './annotate.ts'
12import { depthOf, provisionalService } from './build.ts'
13import { accessOf, payloadOf, scopeFor, sdlOf, validationOf } from './gas.ts'
14import type { Access, Validation } from './gas.ts'
15import type { CallIR, CheckOutcome, Checks, FieldIR } from './ir.ts'
16import { indexSdl } from './schema.ts'
17import type { SchemaIndex } from './schema.ts'
18import { subOperation, unique } from './split.ts'
19
20export type GasTool = 'search' | 'introspect' | 'validate' | 'dry_run'
21export type CallGas = (tool: GasTool, args: Record<string, unknown>) => Promise<McpToolResult>
22
23/** An enrichment read must need no dialog and fit the organization's ceiling. */
24export function isAllowedWithoutPrompt(verdict: { decision: string; ceiling?: string }): boolean {
25 return verdict.decision === 'allow' && (verdict.ceiling === undefined || verdict.ceiling === 'allow')
26}
27
28/**
29 * What enrichment remembers between calls. Everything in it is valid for one
30 * bundleDigest: a result carrying another digest clears it (schema or policy moved).
31 */
32export type EnrichCache = {
33 generation: number
34 bundleDigest?: string
35 scopes?: string[]
36 /** `scope\noperationType\nrootField` → SDL of the root field's signature. */
37 roots: Map<string, string[]>
38 /** `scope\ntype\ndepth` → SDL strings. */
39 types: Map<string, string[]>
40}
41
42export const emptyCache = (): EnrichCache => ({ generation: 0, roots: new Map(), types: new Map() })
43
44/** Schema and scope facts belong to the server that answered them. */
45export function cacheForServer(caches: Map<string, EnrichCache>, server: string): EnrichCache {
46 let cache = caches.get(server)
47 if (cache === undefined) {
48 cache = emptyCache()
49 caches.set(server, cache)
50 }
51 return cache
52}
53
54class GenerationChanged extends Error {
55 constructor() {
56 super('Agent Services bundle changed during analysis')
57 }
58}
59
60function noteDigest(cache: EnrichCache, digest: string | undefined): void {
61 if (digest === undefined || digest === cache.bundleDigest) return
62 if (cache.bundleDigest !== undefined) {
63 cache.scopes = undefined
64 cache.roots.clear()
65 cache.types.clear()
66 cache.generation += 1
67 }
68 cache.bundleDigest = digest
69}
70
71async function read(call: CallGas, cache: EnrichCache, tool: GasTool, args: Record<string, unknown>) {
72 const generation = cache.generation
73 const digest = cache.bundleDigest
74 const payload = payloadOf(await call(tool, args))
75 if (!payload.ok) throw new Error(`${tool}: ${payload.error}`)
76 // An old request must neither roll the digest back nor repopulate a new cache.
77 if (cache.generation !== generation) throw new GenerationChanged()
78 // Initial discovery adopts a digest without advancing the generation. A
79 // concurrent read that began before discovery must still fit that digest.
80 if (cache.bundleDigest !== digest && payload.bundleDigest !== cache.bundleDigest) throw new GenerationChanged()
81 noteDigest(cache, payload.bundleDigest)
82 if (cache.generation !== generation) throw new GenerationChanged()
83 return payload.value
84}
85
86export async function scopesOf(call: CallGas, cache: EnrichCache): Promise<string[]> {
87 if (cache.scopes !== undefined) return cache.scopes
88 const generation = cache.generation
89 const value = await read(call, cache, 'search', { terms: [] })
90 if (cache.generation !== generation) throw new GenerationChanged()
91 const scopes = Array.isArray(value.scopes) ? value.scopes.filter((one): one is string => typeof one === 'string') : []
92 cache.scopes = scopes
93 return scopes
94}
95
96/** The root field's signature: search finds `type Query { field(…): T }` with its description. */
97async function rootSdl(call: CallGas, cache: EnrichCache, scope: string, field: string, opType: string): Promise<string[]> {
98 const key = `${scope}\n${opType}\n${field}`
99 const cached = cache.roots.get(key)
100 if (cached !== undefined) return cached
101 const generation = cache.generation
102 const value = await read(call, cache, 'search', {
103 terms: [field],
104 scope,
105 ...(opType === 'subscription' ? {} : { operationType: opType }),
106 })
107 if (cache.generation !== generation) throw new GenerationChanged()
108 const results = Array.isArray(value.results) ? value.results : []
109 const hit = results.find(
110 (one): one is { operationName: string; types: unknown[] } =>
111 typeof one === 'object' && one !== null && (one as { operationName?: unknown }).operationName === field,
112 )
113 const sdl = hit === undefined ? [] : hit.types.filter((one): one is string => typeof one === 'string')
114 cache.roots.set(key, sdl)
115 return sdl
116}
117
118async function typeSdl(call: CallGas, cache: EnrichCache, scope: string, type: string, depth: number): Promise<string[]> {
119 const key = `${scope}\n${type}\n${depth}`
120 const cached = cache.types.get(key)
121 if (cached !== undefined) return cached
122 const generation = cache.generation
123 const sdl = sdlOf(await read(call, cache, 'introspect', { scope, type, depth }))
124 if (cache.generation !== generation) throw new GenerationChanged()
125 cache.types.set(key, sdl)
126 return sdl
127}
128
129const ROOT_TYPE = { query: 'Query', mutation: 'Mutation', subscription: 'Subscription' } as const
130type OpKind = keyof typeof ROOT_TYPE
131
132/** Indexes the SDL of one scope's `roots` into `index`, which may already hold other scopes' types. */
133async function schemaOf(call: CallGas, cache: EnrichCache, opType: OpKind, roots: readonly FieldIR[], scope: string, index: SchemaIndex): Promise<void> {
134 await Promise.all(
135 roots.map(async root => {
136 indexSdl(await rootSdl(call, cache, scope, root.name, opType), index)
137 const def = index.get(ROOT_TYPE[opType])?.get(root.name)
138 if (def === undefined || root.children.length === 0) return
139 indexSdl(await typeSdl(call, cache, scope, def.namedType, Math.max(1, depthOf(root.children) + 1)), index)
140 }),
141 )
142}
143
144const CHECKS = ['policy', 'validation', 'schema'] as const
145
146/** What one scope's checks found. */
147type ScopeResult = { validation?: Validation; access?: Access; isSchemaRead: boolean; checks: Checks }
148
149const SEVERITY: CheckOutcome[] = ['ok', 'skipped', 'not-allowed', 'failed']
150const worse = (a: CheckOutcome, b: CheckOutcome): CheckOutcome => (SEVERITY.indexOf(b) > SEVERITY.indexOf(a) ? b : a)
151
152/** Runs one scope's checks; only a bundle change escapes to the retry loop. */
153async function checkScope(
154 call: CallGas,
155 cache: EnrichCache,
156 opType: OpKind,
157 scope: string,
158 roots: readonly FieldIR[],
159 operation: string | undefined,
160 index: SchemaIndex,
161): Promise<ScopeResult> {
162 const checks: Checks = { policy: 'ok', validation: 'ok', schema: 'ok' }
163 /** A failure leaves its facts unknown and records why: not allowed by the user's rules, or an Agent Services error. */
164 const settled = <T>(work: Promise<T>, which: readonly (typeof CHECKS)[number][]): Promise<T | undefined> =>
165 work.catch((error: unknown) => {
166 if (error instanceof GenerationChanged) throw error
167 const isNotAllowed = typeof error === 'object' && error !== null && 'decision' in error
168 for (const key of which) checks[key] = isNotAllowed ? 'not-allowed' : 'failed'
169 if (!isNotAllowed && checks.error === undefined) checks.error = String(error instanceof Error ? error.message : error).slice(0, 160)
170 return undefined
171 })
172 // The schema is read from the root fields alone; only validate and dry_run need the operation.
173 const schema = settled(schemaOf(call, cache, opType, roots, scope, index).then(() => true), ['schema'])
174 if (operation === undefined) {
175 // The sub-operation could not be built: this scope's roots stay unchecked.
176 checks.policy = 'skipped'
177 checks.validation = 'skipped'
178 return { isSchemaRead: (await schema) === true, checks }
179 }
180 const [validation, access, isSchemaRead] = await Promise.all([
181 settled(read(call, cache, 'validate', { scope, operation }).then((value): Validation => validationOf(value)), ['validation']),
182 settled(
183 read(call, cache, 'dry_run', { requests: [{ scope, operation }] }).then((value): Access => {
184 const results = Array.isArray(value.results) ? value.results : []
185 const first: unknown = results[0]
186 // Agent Services reports a per-request failure as `{ errors: [string] }` (e.g. `Unknown scope "glean"`).
187 const errors = typeof first === 'object' && first !== null && Array.isArray((first as { errors?: unknown }).errors) ? (first as { errors: unknown[] }).errors : []
188 if (typeof errors[0] === 'string') throw new Error(`dry_run: ${errors[0]}`)
189 const found = accessOf(first)
190 if (found === undefined) throw new Error('dry_run: no result')
191 return found
192 }),
193 ['policy'],
194 ),
195 schema,
196 ])
197 return { ...(validation !== undefined && { validation }), ...(access !== undefined && { access }), isSchemaRead: isSchemaRead === true, checks }
198}
199
200/**
201 * Enriches `ir` (built from `operation`). Roots are grouped by service scope
202 * and each scope is checked on its own, as a sub-operation of just its roots
203 * (src/split.ts), all scopes in parallel; the answers merge back into the one
204 * IR. A root no scope claims keeps its facts unknown. Resolves once every
205 * source has answered or failed; bundle changes escape to the retry loop.
206 */
207async function enrichGeneration(call: CallGas, cache: EnrichCache, ir: CallIR, operation: string, variables: Record<string, unknown>): Promise<CallIR> {
208 const generation = cache.generation
209 if (ir.state === 'unparseable') return ir
210 // Nothing to look up; never leave a call `analyzing`, or the pump retries it forever.
211 if (ir.roots.length === 0 || ir.opType === undefined) return { ...ir, state: 'partial' }
212 const opType = ir.opType
213
214 let scopes: string[] | undefined
215 const lookup: Checks = { policy: 'ok', validation: 'ok', schema: 'ok' }
216 try {
217 scopes = await scopesOf(call, cache)
218 } catch (error) {
219 if (error instanceof GenerationChanged) throw error
220 const isNotAllowed = typeof error === 'object' && error !== null && 'decision' in error
221 const outcome: CheckOutcome = isNotAllowed ? 'not-allowed' : 'failed'
222 for (const key of CHECKS) lookup[key] = outcome
223 if (!isNotAllowed) lookup.error = String(error instanceof Error ? error.message : error).slice(0, 160)
224 }
225 if (cache.generation !== generation) throw new GenerationChanged()
226 const digest = cache.bundleDigest === undefined ? {} : { bundleDigest: cache.bundleDigest }
227 if (scopes === undefined) return annotate(ir, { isIncomplete: true, checks: lookup, ...digest })
228
229 const known = scopes
230 const scopeOf = (root: FieldIR) => scopeFor(root.name, known)
231 const groups = new Map<string, FieldIR[]>()
232 for (const root of ir.roots) {
233 const scope = scopeOf(root)
234 if (scope !== undefined) groups.set(scope, [...(groups.get(scope) ?? []), root])
235 }
236 const hasUnclaimed = ir.roots.some(root => scopeOf(root) === undefined)
237 // One scope claiming every root checks the operation as sent.
238 const isWhole = groups.size === 1 && !hasUnclaimed
239 const index: SchemaIndex = new Map()
240 const results = await Promise.all(
241 [...groups].map(async ([scope, roots]): Promise<ScopeResult> => {
242 const names = new Set(roots.map(root => root.name))
243 const sub = isWhole ? operation : subOperation(operation, variables, field => names.has(field))?.operation
244 return checkScope(call, cache, opType, scope, roots, sub, index)
245 }),
246 )
247 if (cache.generation !== generation) throw new GenerationChanged()
248
249 const checks: Checks = { policy: 'ok', validation: 'ok', schema: 'ok' }
250 for (const result of results) {
251 for (const key of CHECKS) checks[key] = worse(checks[key], result.checks[key])
252 if (checks.error === undefined && result.checks.error !== undefined) checks.error = result.checks.error
253 }
254 // A root no scope claims is not checked at all.
255 if (hasUnclaimed) for (const key of CHECKS) checks[key] = worse(checks[key], 'skipped')
256
257 // Valid only when every scope validated and none was left out; any invalid scope makes the call invalid.
258 const validations = results.flatMap(result => (result.validation === undefined ? [] : [result.validation]))
259 const validation: Validation | undefined = validations.some(one => !one.valid)
260 ? { valid: false, diagnostics: validations.flatMap(one => one.diagnostics) }
261 : validations.length === results.length && !hasUnclaimed
262 ? { valid: true, diagnostics: [] }
263 : undefined
264 const accesses = results.flatMap(result => (result.access === undefined ? [] : [result.access]))
265 const access: Access | undefined =
266 accesses.length === 0
267 ? undefined
268 : { denyOperation: accesses.some(one => one.denyOperation), fields: new Map(accesses.flatMap(one => [...one.fields])) }
269 // Each service once in the order its first root appears; an unclaimed root by its name prefix.
270 const services = unique(ir.roots.flatMap(root => scopeOf(root) ?? provisionalService(root.name) ?? []))
271 const first = ir.roots[0]
272 const firstScope = first === undefined ? undefined : scopeOf(first)
273
274 const found: Enrichment = {
275 isIncomplete: CHECKS.some(key => checks[key] !== 'ok'),
276 checks,
277 services,
278 rootScopes: new Map(ir.roots.flatMap(root => {
279 const scope = scopeOf(root)
280 return scope === undefined ? [] : [[root.path, scope] as const]
281 })),
282 ...(firstScope !== undefined && { scope: firstScope }),
283 ...digest,
284 ...(validation !== undefined && { validation }),
285 ...(access !== undefined && { access }),
286 ...(results.some(result => result.isSchemaRead) && { schema: index }),
287 }
288 return annotate(ir, found)
289}
290
291/** Retry a moving bundle once; never present facts combined across generations. */
292export async function enrich(call: CallGas, cache: EnrichCache, ir: CallIR, operation: string, variables: Record<string, unknown> = {}): Promise<CallIR> {
293 for (let attempt = 0; attempt < 2; attempt++) {
294 try {
295 return await enrichGeneration(call, cache, ir, operation, variables)
296 } catch (error) {
297 if (!(error instanceof GenerationChanged)) throw error
298 }
299 }
300 return annotate(ir, {
301 isIncomplete: true,
302 checks: { policy: 'failed', validation: 'failed', schema: 'failed', error: 'Agent Services bundle changed during analysis' },
303 })
304}
305src/normalize.ts 399 lines1// Normalizes an Agent Services `execute` operation into what will actually run: one
2// operation, fragments inlined, @skip/@include applied, aliases resolved to
3// real field names, variables substituted. Pure; never throws.
4//
5// Treat the operation as untrusted input. Keep fields with unknown conditions
6// and reject duplicate fragment names or input keys that would make the
7// normalized result ambiguous.
8
9import { parse, print, Kind } from './vendor/graphql.js'
10import type {
11 ArgumentNode,
12 DirectiveNode,
13 DocumentNode,
14 FieldNode,
15 FragmentDefinitionNode,
16 InlineFragmentNode,
17 OperationDefinitionNode,
18 SelectionNode,
19 SelectionSetNode,
20 ValueNode,
21} from './vendor/graphql.js'
22
23export type ArgValue = { name: string; value: unknown; fromVariable: boolean; variable?: string }
24export type NormalizedField = {
25 /** The REAL field name, never the alias. */
26 name: string
27 /** Kept only for the raw view. */
28 alias?: string
29 /** Type condition it was selected under (inline fragment or fragment's typeCondition), if any. */
30 onType?: string
31 args: ArgValue[]
32 /** Directive names other than skip/include; unknown ones kept. */
33 directives: string[]
34 children: NormalizedField[]
35}
36export type NormalizeFailure =
37 | 'unparseable'
38 | 'no-operation'
39 | 'multiple-operations'
40 | 'fragment-cycle'
41 | 'unknown-fragment'
42 | 'too-large'
43export type Normalized =
44 | {
45 ok: true
46 opType: 'query' | 'mutation' | 'subscription'
47 opName?: string
48 roots: NormalizedField[]
49 variables: Record<string, unknown>
50 printed: string
51 }
52 | { ok: false; reason: NormalizeFailure; message: string }
53
54/** Most fields the expanded operation may contain. */
55export const MAX_FIELDS = 5000
56/** Deepest field nesting allowed (a root field is depth 1). */
57export const MAX_DEPTH = 64
58/**
59 * Most selections (fields, spreads, inline fragments, skipped or not) the
60 * expansion may visit. Bounds work for fan-out bombs whose leaves are all
61 * skipped, which would never trip MAX_FIELDS.
62 */
63export const MAX_VISITS = 20000
64/** Deepest fragment / inline-fragment / field nesting combined. */
65export const MAX_NESTING = 128
66/** Parser token cap; a larger document is reported as too-large. */
67export const MAX_TOKENS = 100000
68
69class Fail {
70 reason: NormalizeFailure
71 message: string
72 constructor(reason: NormalizeFailure, message: string) {
73 this.reason = reason
74 this.message = message
75 }
76}
77
78const hasOwn = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k)
79
80/** Assigns without invoking setters such as `__proto__`. */
81function put(o: Record<string, unknown>, k: string, v: unknown): void {
82 Object.defineProperty(o, k, { value: v, enumerable: true, writable: true, configurable: true })
83}
84
85type Converted = { value: unknown; fromVariable: boolean }
86
87function convertValue(node: ValueNode, vars: Record<string, unknown>): Converted {
88 switch (node.kind) {
89 case Kind.VARIABLE: {
90 const name = node.name.value
91 return { value: hasOwn(vars, name) ? vars[name] : undefined, fromVariable: true }
92 }
93 case Kind.INT: {
94 const n = Number(node.value)
95 // Out-of-range ints keep their exact source text rather than a rounded
96 // number that would show a different value from the one sent.
97 return { value: Number.isSafeInteger(n) ? n : node.value, fromVariable: false }
98 }
99 case Kind.FLOAT: {
100 const n = Number(node.value)
101 return { value: Number.isFinite(n) ? n : node.value, fromVariable: false }
102 }
103 case Kind.STRING:
104 case Kind.ENUM:
105 return { value: node.value, fromVariable: false }
106 case Kind.BOOLEAN:
107 return { value: node.value, fromVariable: false }
108 case Kind.NULL:
109 return { value: null, fromVariable: false }
110 case Kind.LIST: {
111 let fromVariable = false
112 const value = node.values.map((v) => {
113 const c = convertValue(v, vars)
114 if (c.fromVariable) fromVariable = true
115 return c.value
116 })
117 return { value, fromVariable }
118 }
119 case Kind.OBJECT: {
120 let fromVariable = false
121 const value: Record<string, unknown> = {}
122 for (const f of node.fields) {
123 const key = f.name.value
124 if (hasOwn(value, key)) {
125 // A JS object can show only one of the two; refuse instead of guessing.
126 throw new Fail('unparseable', `Input object has duplicate field "${key}"`)
127 }
128 const c = convertValue(f.value, vars)
129 if (c.fromVariable) fromVariable = true
130 put(value, key, c.value)
131 }
132 return { value, fromVariable }
133 }
134 default:
135 throw new Fail('unparseable', `Unsupported value kind ${(node as { kind: string }).kind}`)
136 }
137}
138
139function convertArgs(args: readonly ArgumentNode[] | undefined, vars: Record<string, unknown>): ArgValue[] {
140 const out: ArgValue[] = []
141 for (const a of args ?? []) {
142 const c = convertValue(a.value, vars)
143 const arg: ArgValue = { name: a.name.value, value: c.value, fromVariable: c.fromVariable }
144 if (a.value.kind === Kind.VARIABLE) arg.variable = a.value.name.value
145 out.push(arg)
146 }
147 return out
148}
149
150type Condition = 'drop' | 'keep' | 'unknown'
151
152/** Evaluates one @skip/@include `if:`. 'unknown' when it cannot be decided. */
153function conditionOf(d: DirectiveNode, vars: Record<string, unknown>): Condition {
154 const ifArg = (d.arguments ?? []).filter((a) => a.name.value === 'if')
155 const only = ifArg.length === 1 ? ifArg[0] : undefined
156 if (!only) return 'unknown'
157 const v = only.value
158 let b: unknown
159 if (v.kind === Kind.BOOLEAN) b = v.value
160 else if (v.kind === Kind.VARIABLE) b = hasOwn(vars, v.name.value) ? vars[v.name.value] : undefined
161 else return 'unknown'
162 if (typeof b !== 'boolean') return 'unknown'
163 const isSkip = d.name.value === 'skip'
164 return (isSkip ? b : !b) ? 'drop' : 'keep'
165}
166
167/**
168 * Applies @skip/@include. Returns null when the selection is dropped, else the
169 * directives to print: decided skip/include removed, undecided ones kept so
170 * the printed view shows the field is conditional.
171 */
172function applyConditions(
173 directives: readonly DirectiveNode[] | undefined,
174 vars: Record<string, unknown>,
175): DirectiveNode[] | null {
176 const kept: DirectiveNode[] = []
177 let drop = false
178 for (const d of directives ?? []) {
179 const n = d.name.value
180 if (n === 'skip' || n === 'include') {
181 const c = conditionOf(d, vars)
182 if (c === 'drop') drop = true
183 else if (c === 'unknown') kept.push(d)
184 } else {
185 kept.push(d)
186 }
187 }
188 return drop ? null : kept
189}
190
191function otherDirectiveNames(directives: readonly DirectiveNode[]): string[] {
192 return directives.map((d) => d.name.value).filter((n) => n !== 'skip' && n !== 'include')
193}
194
195type Ctx = {
196 fragments: Map<string, FragmentDefinitionNode>
197 vars: Record<string, unknown>
198 fields: number
199 visits: number
200}
201
202type Expanded = { fields: NormalizedField[]; selections: SelectionNode[] }
203
204const argsKey = (field: NormalizedField): string => JSON.stringify([...field.args].sort((a, b) => a.name.localeCompare(b.name)).map(arg => [arg.name, arg.value]))
205
206/**
207 * GraphQL field merging: siblings with one response key (`alias ?? name`), one
208 * real name, one type condition and the same arguments are one field, their
209 * selections joined (`issues { ...A ...B }` with `A { key }` and `B { key
210 * summary }` selects `key summary`). Siblings that differ in arguments are not
211 * merged: the call is invalid, and both stay in view.
212 */
213function merged(fields: readonly NormalizedField[]): NormalizedField[] {
214 const out: NormalizedField[] = []
215 const first = new Map<string, NormalizedField>()
216 for (const field of fields) {
217 const id = [field.alias ?? field.name, field.name, field.onType ?? '', argsKey(field)].join('\u0000')
218 const into = first.get(id)
219 if (into === undefined) {
220 first.set(id, field)
221 out.push(field)
222 continue
223 }
224 into.children = merged([...into.children, ...field.children])
225 into.directives = [...new Set([...into.directives, ...field.directives])]
226 }
227 return out
228}
229
230function expand(
231 set: SelectionSetNode,
232 ctx: Ctx,
233 onType: string | undefined,
234 depth: number,
235 nesting: number,
236 stack: string[],
237): Expanded {
238 if (nesting > MAX_NESTING) throw new Fail('too-large', `Selections nest deeper than ${MAX_NESTING}`)
239 const fields: NormalizedField[] = []
240 const selections: SelectionNode[] = []
241 for (const sel of set.selections) {
242 if (++ctx.visits > MAX_VISITS) {
243 throw new Fail('too-large', `Operation expands to more than ${MAX_VISITS} selections`)
244 }
245 const kept = applyConditions(sel.directives, ctx.vars)
246 if (kept === null) continue
247
248 if (sel.kind === Kind.FIELD) {
249 const fieldDepth = depth + 1
250 if (fieldDepth > MAX_DEPTH) throw new Fail('too-large', `Fields nest deeper than ${MAX_DEPTH}`)
251 if (++ctx.fields > MAX_FIELDS) {
252 throw new Fail('too-large', `Operation expands to more than ${MAX_FIELDS} fields`)
253 }
254 const nf: NormalizedField = {
255 name: sel.name.value,
256 args: convertArgs(sel.arguments, ctx.vars),
257 directives: otherDirectiveNames(kept),
258 children: [],
259 }
260 if (sel.alias) nf.alias = sel.alias.value
261 if (onType !== undefined) nf.onType = onType
262 let childSet: SelectionSetNode | undefined
263 if (sel.selectionSet) {
264 // Children of a field start a fresh type scope.
265 const sub = expand(sel.selectionSet, ctx, undefined, fieldDepth, nesting + 1, stack)
266 nf.children = sub.fields
267 childSet = { kind: Kind.SELECTION_SET, selections: sub.selections }
268 }
269 fields.push(nf)
270 const printedField: FieldNode = {
271 kind: Kind.FIELD,
272 alias: sel.alias,
273 name: sel.name,
274 arguments: sel.arguments,
275 directives: kept,
276 selectionSet: childSet,
277 }
278 selections.push(printedField)
279 continue
280 }
281
282 let typeName: string | undefined
283 let typeCondition: InlineFragmentNode['typeCondition']
284 let body: SelectionSetNode
285 let nextStack = stack
286 if (sel.kind === Kind.FRAGMENT_SPREAD) {
287 const name = sel.name.value
288 if (stack.includes(name)) {
289 throw new Fail('fragment-cycle', `Fragment cycle: ${[...stack, name].join(' -> ')}`)
290 }
291 const frag = ctx.fragments.get(name)
292 if (!frag) throw new Fail('unknown-fragment', `Unknown fragment "${name}"`)
293 typeCondition = frag.typeCondition
294 typeName = frag.typeCondition.name.value
295 body = frag.selectionSet
296 nextStack = [...stack, name]
297 } else {
298 typeCondition = sel.typeCondition
299 typeName = sel.typeCondition ? sel.typeCondition.name.value : onType
300 body = sel.selectionSet
301 }
302 const sub = expand(body, ctx, typeName, depth, nesting + 1, nextStack)
303 fields.push(...sub.fields)
304 if (sub.selections.length > 0) {
305 const inline: InlineFragmentNode = {
306 kind: Kind.INLINE_FRAGMENT,
307 typeCondition,
308 directives: kept,
309 selectionSet: { kind: Kind.SELECTION_SET, selections: sub.selections },
310 }
311 selections.push(inline)
312 }
313 }
314 return { fields: merged(fields), selections }
315}
316
317function run(operation: string, given: Record<string, unknown>): Normalized {
318 if (typeof operation !== 'string') {
319 return { ok: false, reason: 'unparseable', message: 'Operation is not a string' }
320 }
321 let doc: DocumentNode
322 try {
323 doc = parse(operation, { noLocation: true, maxTokens: MAX_TOKENS })
324 } catch (e) {
325 const message = e instanceof Error ? e.message : String(e)
326 if (/^Syntax Error: Document contains more th[ae]t? /.test(message)) {
327 return { ok: false, reason: 'too-large', message }
328 }
329 return { ok: false, reason: 'unparseable', message }
330 }
331
332 const ops: OperationDefinitionNode[] = []
333 const fragments = new Map<string, FragmentDefinitionNode>()
334 for (const def of doc.definitions) {
335 if (def.kind === Kind.OPERATION_DEFINITION) ops.push(def)
336 else if (def.kind === Kind.FRAGMENT_DEFINITION) {
337 const name = def.name.value
338 if (fragments.has(name)) {
339 return { ok: false, reason: 'unparseable', message: `Fragment "${name}" is defined more than once` }
340 }
341 fragments.set(name, def)
342 } else {
343 return { ok: false, reason: 'unparseable', message: `Unexpected ${def.kind} in an executable document` }
344 }
345 }
346 const op = ops[0]
347 if (!op) return { ok: false, reason: 'no-operation', message: 'Document has no operation' }
348 if (ops.length > 1) {
349 return {
350 ok: false,
351 reason: 'multiple-operations',
352 message: `Document has ${ops.length} operations; Agent Services runs one per call`,
353 }
354 }
355
356 const vars: Record<string, unknown> = {}
357 const src = given !== null && typeof given === 'object' && !Array.isArray(given) ? given : {}
358 for (const k of Object.keys(src)) put(vars, k, src[k])
359 for (const vd of op.variableDefinitions ?? []) {
360 const name = vd.variable.name.value
361 if (vd.defaultValue && (!hasOwn(vars, name) || vars[name] === undefined)) {
362 put(vars, name, convertValue(vd.defaultValue, {}).value)
363 }
364 }
365
366 const ctx: Ctx = { fragments, vars, fields: 0, visits: 0 }
367 const top = expand(op.selectionSet, ctx, undefined, 0, 0, [])
368
369 const printedOp: OperationDefinitionNode = {
370 kind: Kind.OPERATION_DEFINITION,
371 operation: op.operation,
372 name: op.name,
373 variableDefinitions: op.variableDefinitions,
374 directives: op.directives,
375 selectionSet: { kind: Kind.SELECTION_SET, selections: top.selections },
376 }
377 const printed = print({ kind: Kind.DOCUMENT, definitions: [printedOp] })
378
379 const result: Normalized = {
380 ok: true,
381 opType: op.operation as 'query' | 'mutation' | 'subscription',
382 roots: top.fields,
383 variables: vars,
384 printed,
385 }
386 if (op.name) result.opName = op.name.value
387 return result
388}
389
390export function normalize(operation: string, variables: Record<string, unknown>): Normalized {
391 try {
392 return run(operation, variables)
393 } catch (e) {
394 if (e instanceof Fail) return { ok: false, reason: e.reason, message: e.message }
395 const message = e instanceof Error ? `${e.name}: ${e.message}` : String(e)
396 return { ok: false, reason: 'unparseable', message }
397 }
398}
399src/links.ts 329 lines1// Native deep links: "open this search in the product", so the person can see
2// what the agent's query will see. Pure: no `$`. Hosts come from config, never
3// from call data; call data only ever fills the encoded query value.
4import { DEFAULT_LINKS_TOML } from './default-links.ts'
5import type { CallIR } from './ir.ts'
6import { tenantOf } from './sites.ts'
7import { tryParseToml } from './toml.ts'
8import type { TomlTable } from './toml.ts'
9
10/** One declarative template. */
11export type LinkTemplate = {
12 label: string
13 /** Service scope: the root field's prefix before `_` (`confluence`, `jira`, `glean`, `slack`). */
14 service: string
15 /** Root field name to match: exact, or a prefix when it ends in `*`. */
16 field: string
17 /** The argument whose (string) value fills `{value}`. */
18 arg: string
19 /** A configured base by name. */
20 base: string
21 /** Must start with `{base}`; `{value}` is replaced by the percent-encoded value. */
22 template: string
23}
24
25/** A result row's link rule: the value of `field` on the item, when it fully matches `match`, fills `template` after `{base}`. */
26export type RecordRule = { service: string; field: string; match?: RegExp; base: string; template: string }
27
28/** Named hosts (an empty one means "none"), operation-level searches, and row rules in priority order. Built by loadLinkConfig. */
29export type LinkConfig = { bases: Record<string, string>; searches: LinkTemplate[]; records: RecordRule[] }
30export type Link = { label: string; url: string }
31
32export const MAX_URL = 2000
33export const MAX_LINKS = 3
34
35/** An https base with no credentials, query or fragment, trailing slashes removed; else undefined. */
36export function cleanBase(raw: unknown): string | undefined {
37 if (typeof raw !== 'string') return undefined
38 const text = raw.trim().replace(/\/+$/, '')
39 if (text === '') return undefined
40 try {
41 const url = new URL(text)
42 if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') return undefined
43 if (url.href.replace(/\/+$/, '') !== text) return undefined
44 return text
45 } catch {
46 return undefined
47 }
48}
49
50const MAX_RULES = 64
51const BASE_NAME = /^[a-z][a-z0-9_]*$/
52const MAX_MATCH = 200
53
54/** `{name}rest` with exactly one `{value}` in rest and no other braces; the base name and the template as `{base}rest`. */
55function splitUrl(raw: unknown): { base: string; template: string } | undefined {
56 if (typeof raw !== 'string' || raw.length > 500) return undefined
57 const m = /^\{([a-z][a-z0-9_]*)\}([^{}]*\{value\}[^{}]*)$/.exec(raw)
58 if (m === null || m[1] === 'value' || m[1] === 'base' || m[2]!.split('{value}').length !== 2) return undefined
59 return { base: m[1]!, template: `{base}${m[2]}` }
60}
61
62const tables = (x: unknown): TomlTable[] => (Array.isArray(x) ? x.filter((t): t is TomlTable => typeof t === 'object' && t !== null && !Array.isArray(t)) : [])
63const text = (t: TomlTable, key: string): string | undefined => (typeof t[key] === 'string' && t[key] !== '' ? (t[key] as string) : undefined)
64
65type FileRules = { bases: Record<string, string>; records: RecordRule[]; searches: LinkTemplate[] }
66
67/** One links.toml's contents, bad entries dropped with a problem noted. Base names are checked after the merge. */
68function readRules(file: string, source: string, problems: string[]): FileRules | undefined {
69 const parsed = tryParseToml(source)
70 if (!parsed.ok) {
71 problems.push(`${file}: ${parsed.error}`)
72 return undefined
73 }
74 const doc = parsed.value
75 const out: FileRules = { bases: {}, records: [], searches: [] }
76 const bases = doc.bases
77 if (typeof bases === 'object' && bases !== null && !Array.isArray(bases)) {
78 for (const [name, raw] of Object.entries(bases)) {
79 const clean = raw === '' ? '' : cleanBase(raw)
80 if (!BASE_NAME.test(name) || name === 'value' || name === 'base' || clean === undefined) problems.push(`${file}: bad base ${name}`)
81 else out.bases[name] = clean
82 }
83 }
84 for (const t of tables(doc.record).slice(0, MAX_RULES)) {
85 const url = splitUrl(t.url)
86 const service = text(t, 'service')
87 const field = text(t, 'field')
88 const match = text(t, 'match')
89 let re: RegExp | undefined
90 if (match !== undefined) {
91 try {
92 re = match.length <= MAX_MATCH ? new RegExp(`^(?:${match})$`) : undefined
93 } catch {
94 re = undefined
95 }
96 }
97 if (url === undefined || service === undefined || field === undefined || (match !== undefined && re === undefined) || !/^[\w.-]+$/.test(field) || !/^(\*|[a-z][\w-]*)$/.test(service)) {
98 problems.push(`${file}: skipped a [[record]] entry`)
99 continue
100 }
101 out.records.push({ service, field, ...(re !== undefined && { match: re }), ...url })
102 }
103 for (const t of tables(doc.search).slice(0, MAX_RULES)) {
104 const url = splitUrl(t.url)
105 const [label, service, root, arg] = ['label', 'service', 'root', 'arg'].map(k => text(t, k))
106 if (url === undefined || label === undefined || label.length > 40 || service === undefined || root === undefined || arg === undefined) {
107 problems.push(`${file}: skipped a [[search]] entry`)
108 continue
109 }
110 out.searches.push({ label, service, field: root, arg, ...url })
111 }
112 return out
113}
114
115/**
116 * Where a named base's value comes from: the person's links.toml,
117 * the shipped links.toml, a site learned from an Agent Services response (src/sites.ts), or
118 * nowhere (`unset`: empty in the shipped file, so a response may still teach it).
119 * `off` is the person's own links.toml setting one to "": turned off, never learned.
120 */
121export type BaseSource = 'user' | 'off' | 'shipped' | 'learned' | 'unset'
122
123export type LinkSources = {
124 /** Text of the shipped links.toml; the embedded copy when absent. */
125 shipped?: string
126 /** Text of the user's override file, if there is one. */
127 user?: string
128 /** Sites learned from Agent Services responses (src/sites.ts): each fills only a base no file sets. */
129 learned?: Readonly<Record<string, string | undefined>>
130}
131
132/**
133 * The link configuration: shipped links.toml, then the user's file on top (its
134 * [[record]] rules first, its [bases] replacing), then a site learned from a
135 * response where neither names one. A bad file or entry is skipped and named in
136 * `problems`; nothing throws. `baseSources` says where each named base's value came from.
137 */
138export function loadLinkConfig(sources: LinkSources = {}): { config: LinkConfig; problems: string[]; baseSources: Record<string, BaseSource> } {
139 const problems: string[] = []
140 const shipped = readRules('links.toml', sources.shipped ?? DEFAULT_LINKS_TOML, problems)
141 const user = sources.user === undefined ? undefined : readRules('your links.toml', sources.user, problems)
142 const bases: Record<string, string> = { ...shipped?.bases, ...user?.bases }
143 const from: Record<string, BaseSource> = {}
144 for (const [name, value] of Object.entries(shipped?.bases ?? {})) from[name] = value === '' ? 'unset' : 'shipped'
145 for (const [name, value] of Object.entries(user?.bases ?? {})) from[name] = value === '' ? 'off' : 'user'
146 // Only a base nobody set (empty in the shipped file, absent from the person's own): a site the person turned off stays off.
147 for (const [name, site] of Object.entries(sources.learned ?? {})) {
148 const clean = name === 'atlassian' || name === 'slack' ? tenantOf(name, site) : undefined
149 if (clean !== undefined && Object.hasOwn(bases, name) && from[name] === 'unset') {
150 bases[name] = clean
151 from[name] = 'learned'
152 }
153 }
154 const known = (rule: { base: string }, kind: string) => {
155 if (Object.hasOwn(bases, rule.base)) return true
156 problems.push(`${kind} uses an undefined base {${rule.base}}`)
157 return false
158 }
159 const records = [...(user?.records ?? []), ...(shipped?.records ?? [])].filter(rule => known(rule, '[[record]]'))
160 const searches = [...(shipped?.searches ?? []), ...(user?.searches ?? [])].filter(rule => known(rule, '[[search]]'))
161 return { config: { bases, searches, records }, problems, baseSources: from }
162}
163
164/** The configuration from link files and learned sites. */
165export function configOf(sources: LinkSources = {}): LinkConfig {
166 return loadLinkConfig(sources).config
167}
168
169function matches(t: LinkTemplate, rootName: string): boolean {
170 const named = t.field.endsWith('*') ? rootName.startsWith(t.field.slice(0, -1)) : rootName === t.field
171 const cut = rootName.indexOf('_')
172 return named && cut > 0 && rootName.slice(0, cut) === t.service
173}
174
175/** A lone surrogate (half of a pair, as a model can write one): encodeURIComponent throws on it, and a link is worked out while the pane draws. */
176const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g
177
178/** encodeURIComponent, plus `'` (which URL parsing would otherwise rewrite to %27, breaking the round trip). A lone surrogate becomes U+FFFD, so this never throws. */
179export function encode(value: string): string {
180 return encodeURIComponent(value.replace(LONE_SURROGATE, '\ufffd')).replace(/'/g, '%27')
181}
182
183function resolveBase(base: string, config: LinkConfig): string | undefined {
184 return Object.hasOwn(config.bases, base) ? cleanBase(config.bases[base]) : undefined
185}
186
187function build(t: { base: string; template: string }, value: string, config: LinkConfig): string | undefined {
188 const base = resolveBase(t.base, config)
189 if (base === undefined || value === '') return undefined
190 // Worked out while the pane draws, from text a model wrote: whatever it holds, no link rather than a throw.
191 try {
192 const url = t.template.replace(/\{base\}|\{value\}/g, m => (m === '{base}' ? base : encode(value)))
193 if (url.length > MAX_URL) return undefined
194 const parsed = new URL(url)
195 if (parsed.protocol !== 'https:' || parsed.href !== url || parsed.origin !== new URL(base).origin) return undefined
196 return url
197 } catch {
198 return undefined
199 }
200}
201
202/** Links for a call: built-ins then extras, each over the roots in order; at most 3, de-duplicated. */
203export function linksOf(ir: CallIR, config: LinkConfig = configOf()): Link[] {
204 const out: Link[] = []
205 const seen = new Set<string>()
206 for (const t of config.searches) {
207 for (const root of ir.roots) {
208 if (!matches(t, root.name)) continue
209 const arg = root.args.find(a => a.name === t.arg)
210 if (arg === undefined || typeof arg.value !== 'string') continue
211 const url = build(t, arg.value, config)
212 if (url === undefined || seen.has(url)) continue
213 seen.add(url)
214 out.push({ label: t.label, url })
215 if (out.length >= MAX_LINKS) return out
216 }
217 }
218 return out
219}
220
221// ---- Record links: "open this row", for the items a call returned.
222
223/** Keys of a returned record whose value is a URL to open, trusted only on a configured host. */
224const URL_KEYS = ['url', 'webUrl', 'permalink', 'htmlUrl', 'html_url', 'webViewLink', 'webLink', 'web_url'] as const
225const MAX_VALUE = 200
226
227/** The service a root field belongs to: its prefix before `_`. */
228export const serviceOf = (rootName: string): string => {
229 const cut = rootName.indexOf('_')
230 return cut > 0 ? rootName.slice(0, cut) : ''
231}
232
233/** The origins the person configured: every named base that is set. */
234export function allowedOrigins(config: LinkConfig): Set<string> {
235 const origins = new Set<string>()
236 for (const raw of Object.values(config.bases)) {
237 const base = cleanBase(raw)
238 if (base !== undefined) origins.add(new URL(base).origin)
239 }
240 return origins
241}
242
243/** `raw` when it is a canonical https URL (round-trips unchanged), no credentials, within MAX_URL, on a configured origin; else undefined. */
244export function openableUrl(raw: unknown, config: LinkConfig): string | undefined {
245 if (typeof raw !== 'string' || raw.length > MAX_URL) return undefined
246 try {
247 const url = new URL(raw)
248 if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.href !== raw) return undefined
249 return allowedOrigins(config).has(url.origin) ? raw : undefined
250 } catch {
251 return undefined
252 }
253}
254
255const isRecord = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
256
257/** `a.b` on the item: a flat key of that exact name first (previews keep `fields.key` flat), else a walk down nested objects. */
258function valueAt(item: Record<string, unknown>, path: string): unknown {
259 if (Object.hasOwn(item, path)) return item[path]
260 let at: unknown = item
261 for (const part of path.split('.')) {
262 if (!isRecord(at) || !Object.hasOwn(at, part)) return undefined
263 at = at[part]
264 }
265 return at
266}
267
268/** The URL of a record's own value (the configured host); undefined when no rule fits. */
269function ruleLink(service: string, item: Record<string, unknown>, config: LinkConfig): string | undefined {
270 for (const rule of config.records) {
271 if (rule.service !== '*' && rule.service !== service) continue
272 const raw = valueAt(item, rule.field)
273 const value = typeof raw === 'string' ? raw : typeof raw === 'number' && Number.isSafeInteger(raw) ? String(raw) : ''
274 if (value === '' || value.length > MAX_VALUE || (rule.match !== undefined && !rule.match.test(value))) continue
275 const url = build(rule, value, config)
276 if (url !== undefined) return url
277 }
278 return undefined
279}
280
281/** The URL of a Jira issue key on the configured Atlassian site, or undefined. */
282export function jiraKeyUrl(key: string, config: LinkConfig = configOf()): string | undefined {
283 return ruleLink('jira', { key }, config)
284}
285
286/** The URL of a Confluence page id on the configured Atlassian site, or undefined. */
287export function confluencePageUrl(id: string | number, config: LinkConfig = configOf()): string | undefined {
288 return ruleLink('confluence', { id: String(id) }, config)
289}
290
291/**
292 * Where one returned record opens. A [[record]] rule over the record's own
293 * field first (the host is a configured base); else a URL the response gave,
294 * only if it is canonical https on a configured origin. Response data never
295 * picks the host.
296 */
297export function recordLinkOf(service: string, item: Record<string, unknown>, config: LinkConfig = configOf()): string | undefined {
298 const own = ruleLink(service, item, config)
299 if (own !== undefined) return own
300 for (const key of URL_KEYS) {
301 const url = openableUrl(item[key], config)
302 if (url !== undefined) return url
303 }
304 const webui = valueAt(item, '_links.webui')
305 if (typeof webui === 'string' && webui.startsWith('/') && !webui.startsWith('//')) {
306 const site = config.bases.atlassian
307 return site === undefined ? undefined : openableUrl(`${site}/wiki${webui}`, config)
308 }
309 return undefined
310}
311
312/** The parts of a stored preview item that link matching reads (CallOutcome.preview items). */
313export type PreviewLinkItem = { label?: string; raw?: Record<string, string>; fields?: { name: string; value?: string }[]; url?: string }
314
315const ID_LIKE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
316
317/**
318 * The link for a stored preview item, worked out now from the current config:
319 * the item's kept raw fields, else (outcomes stored before `raw` existed) its
320 * selected `fields`, and an id-like label standing as `key`. The stored `url`
321 * is a last resort, accepted only if still on a configured origin.
322 */
323export function previewLinkOf(service: string, item: PreviewLinkItem, config: LinkConfig = configOf()): string | undefined {
324 const record: Record<string, unknown> = { ...item.raw }
325 for (const field of item.fields ?? []) if (field.value !== undefined && !Object.hasOwn(record, field.name)) record[field.name] = field.value
326 if (typeof item.label === 'string' && ID_LIKE.test(item.label) && !Object.hasOwn(record, 'key')) record.key = item.label
327 return recordLinkOf(service, record, config) ?? openableUrl(item.url, config)
328}
329src/sites.ts 242 lines1// Which Atlassian site and Slack workspace a person's Agent Services reaches, learned
2// from what Agent Services sends back, so record links work with no setup. Pure: no $.
3//
4// Read vendor metadata URL fields (`self`, `url`, `permalink`, `iconUrl`,
5// `_links.base`) under Jira, Confluence or Slack roots. Accept vendor tenant
6// hosts: `<name>.atlassian.net` and `<name>.slack.com`. User content and
7// model text are excluded. Explicit links.toml values take precedence.
8
9import type { CallIR, FieldIR } from './ir.ts'
10import { isRecord } from './guards.ts'
11import type { BaseSource } from './links.ts'
12
13/** The named bases (links.toml `[bases]`) a response can teach. */
14export type Sites = { atlassian?: string; slack?: string }
15
16/** The bases a response can teach, in the order they are listed. */
17export const SITE_KEYS = ['atlassian', 'slack'] as const satisfies readonly (keyof Sites)[]
18
19/** Which base each service's responses can teach. */
20const SITE_OF: Readonly<Record<string, keyof Sites>> = { jira: 'atlassian', confluence: 'atlassian', slack: 'slack' }
21
22/** The service the setup question asks for each site (SETUP_QUERY). */
23const SETUP_SCOPE: Readonly<Record<keyof Sites, string>> = { atlassian: 'jira', slack: 'slack' }
24
25/** A graph's sites by its services (`search`'s scopes): which it can show, and which the setup question can find. */
26export type GraphSites = { shown: (keyof Sites)[]; askable: (keyof Sites)[] }
27
28export function sitesOfGraph(scopes: readonly string[]): GraphSites {
29 const shown = SITE_KEYS.filter(site => scopes.some(scope => Object.hasOwn(SITE_OF, scope) && SITE_OF[scope] === site))
30 return { shown, askable: SITE_KEYS.filter(site => scopes.includes(SETUP_SCOPE[site])) }
31}
32
33/** Each vendor's tenant hosts; the shared ones (the web client, the API, files) are not a tenant. */
34const TENANT: Readonly<Record<keyof Sites, RegExp>> = {
35 atlassian: /^(?!(?:api|www|status|id|admin|auth|start)\.)[a-z0-9][a-z0-9-]{0,62}\.atlassian\.net$/,
36 slack: /^(?!(?:app|api|www|files|slack-files|status|hooks|edgeapi|wss-primary|a)\.)[a-z0-9][a-z0-9-]{0,62}(?:\.enterprise)?\.slack\.com$/,
37}
38
39// Where a response names its own site: metadata the vendor writes, never
40// content a person wrote. Agent Services reaches Jira through Atlassian's API gateway, so
41// every `self` there is on api.atlassian.com (no site), while a status's and a
42// priority's `iconUrl` stay on the site itself (live, Oct 7 2026; a status
43// Jira Software manages, such as In Review, has its icon on the gateway too,
44// so the setup question asks five issues for status and priority). A link in a
45// description, a comment, a page body or a Slack message's blocks is someone's
46// writing and could name any tenant, so no such subtree is looked into.
47
48/** Subtrees that hold what people wrote: never looked into for a site. */
49const CONTENT_KEYS = new Set([
50 'description', 'body', 'comment', 'comments', 'content', 'text', 'summary', 'environment', 'renderedFields', 'excerpt',
51 'attrs', 'marks', 'storage', 'atlas_doc_format', 'atlasDocFormat', 'view', 'value', 'object',
52 'blocks', 'attachments', 'files', 'file', 'elements', 'rich_text', 'unfurl', 'unfurls',
53])
54
55/** Objects whose `iconUrl` Jira serves from the site. */
56const ICON_OWNERS = new Set(['status', 'priority', 'issuetype'])
57
58/** Whether `key` (under `parent`, at `depth` below the root) is a place the vendor writes its own site. */
59function isSiteKey(site: keyof Sites, key: string, parent: string | undefined, depth: number, authTest: boolean): boolean {
60 if (site === 'atlassian') return key === 'self' || (key === 'base' && parent === '_links') || (key === 'iconUrl' && parent !== undefined && ICON_OWNERS.has(parent))
61 // Slack: the auth test's own `url` (the root object's), and a permalink Slack made for a message.
62 return (key === 'url' && depth === 1 && authTest) || key === 'permalink'
63}
64
65/** Known metadata containers inside JSON scalars; arbitrary JSON keys are content. */
66function metadataKeys(site: keyof Sites, key: string, root: boolean): readonly string[] {
67 if (site === 'slack') {
68 if (root) return ['url', 'permalink', 'messages', 'message', 'matches']
69 if (['messages', 'message', 'matches'].includes(key)) return ['permalink', 'messages', 'matches']
70 return []
71 }
72 if (key === '_links') return ['base']
73 if (ICON_OWNERS.has(key)) return ['self', 'iconUrl']
74 if (key === 'fields') return [...ICON_OWNERS]
75 if (root || ['issues', 'results', 'values'].includes(key)) return ['self', '_links', 'fields', 'issues', 'results', 'values']
76 return []
77}
78
79/** Conflicting aliases have no trustworthy mapping from response key to field. */
80function responseFields(fields: readonly FieldIR[]): FieldIR[] {
81 const names = new Map<string, string | undefined>()
82 for (const field of fields) {
83 const key = field.alias ?? field.name
84 if (!names.has(key)) names.set(key, field.name)
85 else if (names.get(key) !== field.name) names.set(key, undefined)
86 }
87 return fields.filter(field => names.get(field.alias ?? field.name) === field.name)
88}
89
90/** How much of a response is looked through: enough for any real one, bounded for a huge one. */
91const MAX_NODES = 5_000
92const MAX_DEPTH = 12
93
94/** `https://<tenant>` when `value` is an https URL on one of `site`'s tenant hosts, with no credentials or port. */
95export function tenantOf(site: keyof Sites, value: unknown): string | undefined {
96 if (typeof value !== 'string' || value.length > 2_000 || !value.startsWith('https://')) return undefined
97 let url: URL
98 try {
99 url = new URL(value)
100 } catch {
101 return undefined
102 }
103 if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.port !== '') return undefined
104 const host = url.hostname.toLowerCase()
105 return TENANT[site].test(host) ? `https://${host}` : undefined
106}
107
108/**
109 * The sites an Agent Services response shows, from the vendor's own metadata under each
110 * Jira, Confluence or Slack root's part of `data` (isSiteKey), never from what
111 * a person wrote there (CONTENT_KEYS); the first one found for each.
112 */
113export function sitesIn(ir: CallIR, response: unknown): Sites {
114 const found: Sites = {}
115 const data = isRecord(response) && isRecord(response.data) ? response.data : undefined
116 if (data === undefined) return found
117 let nodes = 0
118 const look = (site: keyof Sites, value: unknown, key: string, parent: string | undefined, depth: number, authTest: boolean, field?: FieldIR) => {
119 if (found[site] !== undefined || nodes++ > MAX_NODES || depth > MAX_DEPTH) return
120 // Check the real selected field name, including for scalar strings.
121 if (depth > 0 && CONTENT_KEYS.has(key)) return
122 if (typeof value === 'string') {
123 if (isSiteKey(site, key, parent, depth, authTest)) {
124 const tenant = tenantOf(site, value)
125 if (tenant !== undefined) found[site] = tenant
126 }
127 return
128 }
129 if (Array.isArray(value)) {
130 for (const item of value) look(site, item, key, parent, depth + 1, authTest, field)
131 return
132 }
133 if (!isRecord(value)) return
134 if (field !== undefined && field.children.length > 0) {
135 for (const child of responseFields(field.children)) {
136 const responseKey = child.alias ?? child.name
137 if (Object.hasOwn(value, responseKey)) look(site, value[responseKey], child.name, key, depth + 1, authTest, child)
138 }
139 } else {
140 // JSON scalars have no field selection. Restrict traversal to known
141 // metadata containers; arbitrary nested objects may contain user content.
142 for (const inner of metadataKeys(site, key, parent === undefined)) {
143 if (Object.hasOwn(value, inner)) look(site, value[inner], inner, key, depth + 1, authTest)
144 }
145 }
146 }
147 for (const root of responseFields(ir.roots)) {
148 const service = root.service ?? root.name.slice(0, Math.max(0, root.name.indexOf('_')))
149 // Own keys only: a root named `constructor_x` or `toString_x` must not reach Object's.
150 const site = Object.hasOwn(SITE_OF, service) ? SITE_OF[service] : undefined
151 if (site === undefined) continue
152 const key = root.alias ?? root.name
153 if (Object.hasOwn(data, key)) look(site, data[key], root.name, undefined, 0, /^slack_auth_?test$/i.test(root.name), root)
154 }
155 return found
156}
157
158/**
159 * The bases a response may still teach: empty in the shipped file and named by
160 * no file of the person's, so nothing is configured and nothing
161 * learned. The cheap gate before a response is parsed for a site.
162 */
163export function learnable(sources: Readonly<Record<string, BaseSource>>): (keyof Sites)[] {
164 return SITE_KEYS.filter(site => sources[site] === 'unset')
165}
166
167/**
168 * Sites read back from storage, each checked again as a response's would be:
169 * a stored value that is no longer a vendor tenant host (an edited file, a
170 * stricter rule) is dropped, never trusted for having been stored.
171 */
172export function learnedOf(stored: unknown): Sites {
173 const found: Sites = {}
174 if (!isRecord(stored)) return found
175 for (const site of SITE_KEYS) {
176 const tenant = tenantOf(site, stored[site])
177 if (tenant !== undefined) found[site] = tenant
178 }
179 return found
180}
181
182/** `yourco.atlassian.net` from a base URL; the text itself when it is not one. */
183export function hostOf(base: string): string {
184 try {
185 return new URL(base).host
186 } catch {
187 return base
188 }
189}
190
191/** What each named base is for. */
192const BASE_TITLE: Readonly<Record<string, string>> = { atlassian: 'Jira and Confluence', slack: 'Slack', glean: 'Glean' }
193/** What a person writes to set one, as an example only. */
194const BASE_EXAMPLE: Readonly<Record<string, string>> = { atlassian: 'https://yourco.atlassian.net', slack: 'https://yourco.slack.com', glean: 'https://app.glean.com' }
195
196const SOURCE_WORDS: Readonly<Record<BaseSource, string>> = {
197 user: 'from your links.toml',
198 off: 'turned off by your links.toml, so no response will teach it',
199 shipped: 'the shipped default',
200 learned: 'learned from an Agent Services response; /gas links forget clears it',
201 unset: 'not set',
202}
203
204/**
205 * One line for each named base: its value and where it comes from, and for an
206 * unset one how to set it. `file` is where the person's links.toml goes.
207 */
208export function describeBases(bases: Readonly<Record<string, string>>, sources: Readonly<Record<string, BaseSource>>, file: string | undefined): string[] {
209 const where = file ?? 'your links.toml'
210 return Object.keys(bases).map(name => {
211 const source = sources[name] ?? 'user'
212 const label = `${name}${Object.hasOwn(BASE_TITLE, name) ? ` (${BASE_TITLE[name]})` : ''}`
213 if (source !== 'unset') return ` ${label}: ${bases[name] === '' ? 'no site' : bases[name]} (${SOURCE_WORDS[source]})`
214 const example = Object.hasOwn(BASE_EXAMPLE, name) ? BASE_EXAMPLE[name] : 'https://wiki.example.com'
215 const learns = (SITE_KEYS as readonly string[]).includes(name) ? ' The first Agent Services response that shows it teaches it.' : ''
216 return ` ${label}: not set. Set it with ${name} = "${example}" under [bases] in ${where}.${learns}`
217 })
218}
219
220const SETUP_QUERY: Readonly<Record<keyof Sites, string>> = {
221 atlassian: '`query FindAtlassianSite { jira_searchAndReconsileIssuesUsingJql(jql: "updated >= -365d ORDER BY updated DESC", maxResults: 5, fields: ["status", "priority"]) { issues { fields } } }`',
222 slack: '`query FindSlackWorkspace { slack_authTest { url } }`',
223}
224
225/**
226 * What `/gas setup` puts in the prompt box for the person to send: read-only
227 * Agent Services queries whose answers carry their Atlassian site and Slack workspace,
228 * one for each of `wanted`. The pane learns the sites from those answers, not
229 * from Claude.
230 */
231export function setupPrompt(wanted: readonly (keyof Sites)[] = SITE_KEYS): string {
232 const [only, ...more] = wanted
233 if (only !== undefined && more.length === 0) {
234 const [what, kind] = only === 'slack' ? ['Slack', 'workspace'] : ['Jira and Confluence', 'site']
235 return `Run this read-only GraphOS Agent Services query so the GraphOS Inspector pane can link ${what} records to our ${kind}. Change nothing, and skip it if it fails or needs a sign-in: ${SETUP_QUERY[only]}.`
236 }
237 return `Run these two read-only GraphOS Agent Services queries, each on its own, so the GraphOS Inspector pane can link Jira, Confluence and Slack records to our sites. Change nothing, and skip one that fails or needs a sign-in: ${SITE_KEYS.map(site => SETUP_QUERY[site]).join(' and ')}.`
238}
239
240/** Both questions: what `/gas setup` drafts when neither site is set. */
241export const SETUP_PROMPT = setupPrompt(SITE_KEYS)
242src/setup.ts 110 lines1// What `/gas setup` reports: only what is left to do, as numbered steps, or one
2// line saying it is ready. Pure: no $. The hook gathers the facts (the engine's
3// version, the tool list, the permission checks, the link sources, the trust
4// rules); this words them. Where each site comes from is /gas links's to say.
5
6import { escapeText } from './escape.ts'
7import type { BaseSource } from './links.ts'
8import { learnable } from './sites.ts'
9import type { GraphSites, Sites } from './sites.ts'
10import { MIN_CLAUDE_CODE, isOlder, versionNote } from './version.ts'
11
12/**
13 * What the permission check answered for one tool: `allow` (and not capped by
14 * the person's organization), `ask`, `deny`, or `capped` (allowed, but the
15 * organization's policy stands above it).
16 */
17export type ToolState = 'allow' | 'ask' | 'deny' | 'capped'
18
19export type SetupFacts = {
20 /** `$.session.version()`; undefined when this engine has no such call (the mod loads from 2.1.287). */
21 version?: { version: string; base?: string }
22 /** The Agent Services servers' names (src/servers.ts); undefined when the tool list could not be read. */
23 servers?: readonly string[]
24 /** The check's answer for each read-only tool of each server (the tools the mod calls, which change nothing). */
25 tools: readonly { server: string; tool: string; state: ToolState }[]
26 /** The bases' values and where each comes from, as links.toml and the options set them. */
27 bases: Readonly<Record<string, string>>
28 sources: Readonly<Record<string, BaseSource>>
29 /** Where the person's links.toml goes. */
30 linksFile?: string
31 /** Which sites the graph's services can show and the setup question can find (src/sites.ts sitesOfGraph); undefined when `search` could not be called. */
32 graph?: GraphSites
33 /** What was done about the unset sites the question can find: drafted into the prompt box, or why not. Absent when nothing was tried. */
34 draft?: { isDrafted: true } | { isDrafted: false; why: string }
35 trust: { count: number; isOff: boolean; isFileChanged: boolean; file?: string; example: string }
36}
37
38const server = (name: string) => escapeText(name, 200).text
39const list = (names: readonly string[]) => (names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`)
40
41/** One step: its first line, then the lines under it. */
42type Step = string[]
43
44export const READY = 'GraphOS Inspector is ready. Ask Claude for anything from GraphOS Agent Services; the pane opens on a wide terminal, or with /gas.'
45
46function versionSteps(version: SetupFacts['version']): Step[] {
47 if (version === undefined) return [[`Check that Claude Code is ${MIN_CLAUDE_CODE} or later (\`claude --version\`; \`claude update\` brings it up to date): its version could not be read here.`]]
48 const note = versionNote(version.base, 'GraphOS Agent Mods')
49 if (note !== undefined) return [[`Update Claude Code: ${note}`]]
50 // Skip the release comparison for development builds.
51 return version.base !== undefined && isOlder(version.base, MIN_CLAUDE_CODE) === true ? [[`Update Claude Code to ${MIN_CLAUDE_CODE} or later (\`claude update\`).`]] : []
52}
53
54function connectorSteps(servers: SetupFacts['servers']): Step[] {
55 if (servers === undefined) return [['Check the GraphOS Agent Services connector: Claude Code could not list its tools just now. Run /mcp to see whether it is connected, then /gas setup again.']]
56 if (servers.length > 0) return []
57 return [[
58 'Connect GraphOS Agent Services: add the "GraphOS Agent Services" connector at claude.ai (Settings, then Connectors) and sign in to it. Claude Code must be signed in to the same claude.ai account (/login); /mcp then lists it. Then run /gas setup again.',
59 'This mod requires GraphOS Agent Services. The open-source Apollo MCP Server does not provide the required Agent Services tools.',
60 ]]
61}
62
63function permissionNotes(facts: SetupFacts): string[] {
64 const notes: string[] = []
65 const servers = facts.servers ?? []
66 for (const name of servers) {
67 const own = facts.tools.filter(one => one.server === name)
68 const asks = own.filter(one => one.state === 'ask').map(one => one.tool)
69 const denied = own.filter(one => one.state === 'deny').map(one => one.tool)
70 const capped = own.filter(one => one.state === 'capped').map(one => one.tool)
71 const label = servers.length > 1 ? ` on ${server(name)}` : ''
72 // Optional: the pane works without them, marking access as not checked, and says so on the call itself.
73 if (asks.length > 0) notes.push(`Optional: allow Agent Services' ${list(asks)}${label} in /permissions and the pane checks access and schema before you approve. None of them changes data.`)
74 if (denied.length > 0) notes.push(`A deny rule (yours or your organization's) blocks ${list(denied)}${label}, so the pane cannot check access with ${denied.length === 1 ? 'it' : 'them'}.`)
75 if (capped.length > 0) notes.push(`Your organization's policy limits ${list(capped)}${label}, which a rule of yours cannot widen.`)
76 }
77 return notes
78}
79
80const SITE_NAME: Readonly<Record<keyof Sites, string>> = { atlassian: 'Jira and Confluence', slack: 'Slack' }
81
82/** The read-only question that finds the sites, when one was drafted (or could not be). Every other site is learned from the first response that shows it. */
83function siteSteps(facts: SetupFacts): Step[] {
84 const graph = facts.graph
85 if (graph === undefined || facts.draft === undefined) return []
86 const asked = learnable(facts.sources).filter(site => graph.shown.includes(site) && graph.askable.includes(site))
87 if (asked.length === 0) return []
88 const names = asked.length > 1 ? 'Jira, Confluence and Slack' : SITE_NAME[asked[0] ?? 'atlassian']
89 if (facts.draft.isDrafted) return [[`Send the read-only question in your prompt box to configure links for ${names} records on your ${asked.length === 1 && asked[0] === 'slack' ? 'workspace' : 'sites'}. Agent Services' response supplies the site URL.`]]
90 return [[`Run /gas setup again: the read-only question that finds your ${names} ${asked.length === 1 && asked[0] === 'slack' ? 'workspace' : 'sites'} could not be put in your prompt box (${facts.draft.why}).`]]
91}
92
93function trustNotes(trust: SetupFacts['trust']): string[] {
94 const where = trust.file === undefined ? 'trust.graphql' : trust.file
95 const out = trust.count === 0 ? [] : [`Trust rules: ${trust.count} loaded${trust.isOff ? ', switched off for this session (/gas trust on)' : ''}; /gas trust lists them.`]
96 if (trust.isFileChanged) out.push(`${where} was saved since the rules were read: run /gas trust to load it.`)
97 return out
98}
99
100/** The whole report, for the person to read: the steps left, else that it is ready. */
101export function setupReport(facts: SetupFacts): string {
102 const connector = connectorSteps(facts.servers)
103 // Nothing past the connector can be checked without one.
104 const steps = [...versionSteps(facts.version), ...connector, ...(connector.length === 0 ? siteSteps(facts) : [])]
105 const notes = [...(connector.length === 0 ? permissionNotes(facts) : []), ...trustNotes(facts.trust)]
106 if (steps.length === 0) return [READY, ...notes].join('\n')
107 const numbered = steps.flatMap((step, index) => [`${index + 1}. ${step[0] ?? ''}`, ...step.slice(1).map(line => ` ${line}`)])
108 return [`GraphOS Inspector setup: ${steps.length === 1 ? 'one step' : `${steps.length} steps`} left.`, '', ...numbered, ...(notes.length === 0 ? [] : ['', ...notes])].join('\n')
109}
110src/version.ts 29 lines1// The Claude Code release this mod needs, and what to say when the running
2// one is older. Pure: no $.
3//
4// Mods load from 2.1.287; below that nothing of this plugin runs, so the
5// README says to check before installing. From 2.1.287 up the mod loads and
6// checks for itself (`$.session.version()` at session start), and says once
7// when the engine is older than the release it was tested on.
8
9/** The oldest release the mod was tested on: the pane, the verdict row, trust rules, large results. */
10export const MIN_CLAUDE_CODE = '2.1.290'
11
12/** `a` before `b`, as `2.1.288` is before `2.1.290`; undefined when either is not a dotted release. */
13export function isOlder(a: string, b: string): boolean | undefined {
14 const parts = (v: string) => (/^\d+(\.\d+){1,3}$/.test(v) ? v.split('.').map(Number) : undefined)
15 const [x, y] = [parts(a), parts(b)]
16 if (x === undefined || y === undefined) return undefined
17 for (let at = 0; at < Math.max(x.length, y.length); at++) {
18 const [p, q] = [x[at] ?? 0, y[at] ?? 0]
19 if (p !== q) return p < q
20 }
21 return false
22}
23
24/** The line to log when the running release (`base`, as `$.session.version()` gives it) is older than the minimum; undefined otherwise. */
25export function versionNote(base: string | undefined, plugin: string): string | undefined {
26 if (base === undefined || isOlder(base, MIN_CLAUDE_CODE) !== true) return undefined
27 return `${plugin} needs Claude Code ${MIN_CLAUDE_CODE} or later (this is ${base}): run \`claude update\`, then restart Claude Code.`
28}
29src/open.ts 35 lines1// Opening a link in the system browser: the decision, pure. Only a canonical
2// https URL on a host the person configured is ever handed to the opener, as an
3// argument vector (never a shell). The host call itself lives in hooks/register.tsx.
4import { openableUrl } from './links.ts'
5import type { LinkConfig } from './links.ts'
6
7export const OPEN_TIMEOUT_MS = 5000
8
9/** The opener command for a `uname -s`, or undefined (Windows and the rest are skipped). */
10export function openerFor(uname: string): readonly string[] | undefined {
11 const name = uname.trim()
12 if (name === 'Darwin') return ['open']
13 if (name === 'Linux') return ['xdg-open']
14 return undefined
15}
16
17/** The argv that opens `url`, or why not. */
18export function openArgv(url: unknown, config: LinkConfig, uname: string, fromGas: readonly string[] = []): { argv: readonly string[] } | { refused: string } {
19 // Agent Services' own link to connect an account (UPSTREAM_AUTH_REQUIRED) is on Agent Services' host, never a configured base: it opens when it is exactly one the pane drew.
20 const safe = openableUrl(url, config) ?? (typeof url === 'string' && fromGas.includes(url) ? canonicalHttps(url) : undefined)
21 if (safe === undefined) return { refused: 'a link outside the configured hosts' }
22 const command = openerFor(uname)
23 return command === undefined ? { refused: 'no opener for this platform' } : { argv: [...command, safe] }
24}
25
26/** `url` when it is an https URL written exactly as it parses, with no credentials; else undefined. */
27function canonicalHttps(url: string): string | undefined {
28 try {
29 const parsed = new URL(url)
30 return parsed.protocol === 'https:' && parsed.href === url && parsed.username === '' && parsed.password === '' ? url : undefined
31 } catch {
32 return undefined
33 }
34}
35