SLOPSHOPPER

GraphOS Agent Mods

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…

newpanespinnerrowsguardcommand
★ 2v?Elastic-2.0updated 2026-10-08inanna-apollo/graphos-agent-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · graphos-agent-mods
│ ┃ GraphOS Inspector ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ● graphos-agent-mods: GraphOS Inspector: GraphOS Agent Mods needs Cla │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ No GraphOS Agent Services call yet. › /gas │ ┃ Each Agent Services execute call shows here ⎿ graphos-agent-mods: GraphOS Inspector opened. │ ┃ before you approve it. │ ┃ Open this pane any time with /gas │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GraphOS Inspector
No GraphOS Agent Services call yet. Each Agent Services execute call shows here before you approve it. Open this pane any time with /gas
Pane · gas-raw
No Agent Services call yet.
README

GraphOS Agent Mods

<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.

Install

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.

What it shows

  • The operation and its arguments, with schema descriptions, types, paging, and policy information.
  • A result preview with links to records, plus an estimate of response size and which fields account for it.
  • A proposed-write preview derived from the call's arguments. It shows new values; it does not fetch current records or compare old and new state.
  • Optional GraphQL trust rules for reads you choose to run without a permission dialog. These rules only turn an 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.

Commands

/gas opens the pane on the current or last Agent Services call; /gas setup reports anything missing. The user guide lists every command.

Updating

In /plugin, open Marketplaces, select graphos-experiments, choose Update marketplace, then run /reload-plugins. The user guide has the shell commands and auto-update.

Limits and details

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.

Source 83 files
hooks/register.tsx 1487 lines
1// 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 lines
1// 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}
55
src/escape.ts 40 lines
1// 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}
40
src/annotate.ts 198 lines
1// 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}
198
src/build.ts 59 lines
1// 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}
59
src/enrich.ts 305 lines
1// 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}
305
src/normalize.ts 399 lines
1// 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}
399
src/links.ts 329 lines
1// 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}
329
src/sites.ts 242 lines
1// 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)
242
src/setup.ts 110 lines
1// 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}
110
src/version.ts 29 lines
1// 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}
29
src/open.ts 35 lines
1// 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