From 666dab5a330fadcd022e08cd2ff769f82c8a61c0 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 17:57:26 +0300 Subject: [PATCH 1/3] feat(cli): add headless agent visibility --- README.md | 19 + docs/architecture.md | 6 + src/agents.ts | 930 +++++++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 14 + tests/agents.test.ts | 341 ++++++++++++++++ 5 files changed, 1310 insertions(+) create mode 100644 src/agents.ts create mode 100644 tests/agents.test.ts diff --git a/README.md b/README.md index 8a3372d..7785e93 100644 --- a/README.md +++ b/README.md @@ -25,12 +25,31 @@ The package installs the local data directory at `~/.hasna/tai/`. ```bash tai propose "show the largest files in this repo" tai plan "inspect the repo status, run the relevant checks, and show me the safe next commands" +tai agents +tai agents --json +tai agents show codewith: tai classify "rm -rf dist" tai run "ls -la" --yes ``` `tai run` streams stdout/stderr directly and exits with the child command status. It preserves the current working directory and environment by default, and forwards common process signals to the child. +### Headless agent visibility + +`tai agents [--limit <1-200>]` is a local, stateless, read-only projection of the installed Codewith, Claude, and Todos agent surfaces. The default limit is 50. `tai agents show :` selects one exact normalized record. Add `--json` to either form for the versioned machine contract. + +Each source is invoked at most once per command. Missing providers fail soft: available records are still returned with `partial: true` and bounded source diagnostics. If every provider fails, the command exits nonzero. The command does not persist data, inspect transcripts or prompts, call a provider once per record, switch profiles, or own agent lifecycle state. + +JSON v1 has these stable fields: + +- Envelope: `schema_version` (number, currently `1`), `generated_at` (ISO-8601 string), `partial` (boolean), `sources` (source diagnostics), and `agents` (normalized records). Command errors add a bounded `error` object. +- Source diagnostic: `provider` (`codewith|claude|todos`), `status` (`ok|partial|unavailable|error`), `freshness_at` (ISO-8601 string or `null`), and optional `error: {code, message}`. +- Agent identity/state: `id`, `provider`, `run_id`, `status`, `active`, `started_at`, `updated_at`, `freshness_at`. +- Agent context: `worktree`, `branch`, `last_tool_call: {name, at, summary}`, `goal: {id, title, status}`, `task: {id, short_id, title, status}`, and `profile: {alias}`. +- `gaps` is an array of explicit missing or stale normalized fields. Unavailable provider fields remain `null`; they are never inferred from prompt, transcript, or matching text. + +Text fields and diagnostics are bounded and redacted. Profile output accepts only a safe configured alias such as `account001`; it never includes email, account ID, auth path, token, or credential metadata. Provider command output is captured under a hard byte and time limit, and raw transcripts, full prompts, full tool arguments, and environment dumps are not projected. + ## Provider Routing Defaults are local-first and configurable: diff --git a/docs/architecture.md b/docs/architecture.md index 41fa965..dbabac9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,12 @@ Routing is local-first: Model IDs are intentionally configuration-driven because availability is time-sensitive. +## Agent Visibility Facade + +The `tai agents` surface is separate from model routing. It is a stateless, read-only facade over the installed Codewith background-agent list, Claude agent list, and Todos active-task command surfaces. A request makes one bounded, non-shell provider call per source and projects the returned batch locally; it never performs a provider read for each result. + +The normalized schema records only bounded status/context fields exposed by those list surfaces. Missing worktree, branch, tool, goal, task, profile, timestamp, or freshness data stays `null` and is named in `gaps`. Source failures are isolated and diagnosed with stable codes. TAI owns no database, daemon, cache, task assignment, authorization, or agent lifecycle state, and this surface contains no stop, retry, resume, profile-switch, notification, fleet, web, or TUI behavior. + ## Safety Policy Read-only commands may run without confirmation. Writes, network calls, package manager operations, process control, and mutable git operations require confirmation. Destructive operations, credential disclosure, privilege escalation, deploy/publish, and force-push operations are blocked unless the user uses an explicit override. diff --git a/src/agents.ts b/src/agents.ts new file mode 100644 index 0000000..e7a6051 --- /dev/null +++ b/src/agents.ts @@ -0,0 +1,930 @@ +import { spawn } from "node:child_process"; +import { isAbsolute } from "node:path"; +import { redactSensitiveText } from "./redaction"; + +export const AGENTS_SCHEMA_VERSION = 1 as const; +export const DEFAULT_AGENTS_LIMIT = 50; +export const MAX_AGENTS_LIMIT = 200; + +const MAX_STDOUT_BYTES = 4 * 1024 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +const PROVIDER_TIMEOUT_MS = 15_000; + +export type AgentProvider = "codewith" | "claude" | "todos"; +export type AgentSourceStatus = "ok" | "partial" | "unavailable" | "error"; + +export interface AgentSourceError { + code: string; + message: string; +} + +export interface AgentSource { + provider: AgentProvider; + status: AgentSourceStatus; + freshness_at: string | null; + error?: AgentSourceError; +} + +export interface AgentRecord { + id: string; + provider: AgentProvider; + run_id: string; + status: string; + active: boolean; + started_at: string | null; + updated_at: string | null; + worktree: string | null; + branch: string | null; + last_tool_call: { + name: string | null; + at: string | null; + summary: string | null; + }; + goal: { + id: string | null; + title: string | null; + status: string | null; + }; + task: { + id: string | null; + short_id: string | null; + title: string | null; + status: string | null; + }; + profile: { + alias: string | null; + }; + freshness_at: string | null; + gaps: string[]; +} + +export interface AgentsEnvelope { + schema_version: typeof AGENTS_SCHEMA_VERSION; + generated_at: string; + partial: boolean; + sources: AgentSource[]; + agents: AgentRecord[]; + error?: AgentSourceError; +} + +export interface ProviderCommand { + provider: AgentProvider; + command: string; + args: string[]; +} + +export interface ProviderCommandResult { + stdout: string; + stderr: string; + exitCode: number | null; + failure?: "not-found" | "timeout" | "output-limit" | "spawn-error"; +} + +export type ProviderCommandRunner = (command: ProviderCommand) => Promise; + +export interface CollectAgentsOptions { + runner?: ProviderCommandRunner; + now?: () => Date; +} + +export interface AgentsCliResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface ProviderCollection { + source: AgentSource; + agents: AgentRecord[]; +} + +interface NormalizeResult { + agents: AgentRecord[]; + skipped: number; +} + +interface ParsedAgentsArguments { + json: boolean; + limit: number; + mode: "list" | "show"; + id: string | null; +} + +const PROVIDER_COMMANDS: ProviderCommand[] = [ + { + provider: "codewith", + command: "codewith", + args: ["agent", "list", "--json", "--limit", String(MAX_AGENTS_LIMIT)] + }, + { + provider: "claude", + command: "claude", + args: ["agents", "--json"] + }, + { + provider: "todos", + command: "todos", + args: ["active", "--json"] + } +]; + +export async function collectAgentVisibility(options: CollectAgentsOptions = {}): Promise { + const runner = options.runner ?? runProviderCommand; + const generatedAt = (options.now ?? (() => new Date()))().toISOString(); + const collections = await Promise.all( + PROVIDER_COMMANDS.map((command) => collectProvider(command, runner, generatedAt)) + ); + const sources = collections.map(({ source }) => source); + const agents = sortAgents(dedupeAgents(collections.flatMap((collection) => collection.agents))); + + return { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: sources.some(({ status }) => status !== "ok"), + sources, + agents + }; +} + +export async function runAgentsCli( + args: string[], + options: CollectAgentsOptions = {} +): Promise { + const generatedAt = (options.now ?? (() => new Date()))().toISOString(); + let parsed: ParsedAgentsArguments; + + try { + parsed = parseAgentsArguments(args); + } catch (error) { + const diagnostic = makeDiagnostic("invalid-arguments", error); + const json = args.includes("--json"); + return { + exitCode: 2, + stdout: json ? JSON.stringify(errorEnvelope(generatedAt, diagnostic), null, 2) : "", + stderr: json ? "" : `error: ${diagnostic.message}` + }; + } + + if (parsed.mode === "show" && !isValidAgentId(parsed.id)) { + const diagnostic: AgentSourceError = { + code: "invalid-agent-id", + message: "Agent ID must use :." + }; + return { + exitCode: 2, + stdout: parsed.json ? JSON.stringify(errorEnvelope(generatedAt, diagnostic), null, 2) : "", + stderr: parsed.json ? "" : `error: ${diagnostic.message}` + }; + } + + const envelope = await collectAgentVisibility(options); + const availableSources = envelope.sources.filter( + ({ status }) => status === "ok" || status === "partial" + ); + + if (availableSources.length === 0) { + const diagnostic: AgentSourceError = { + code: "all-sources-failed", + message: "No authoritative agent source is currently available." + }; + const failedEnvelope = { ...envelope, error: diagnostic }; + return { + exitCode: 3, + stdout: parsed.json ? JSON.stringify(failedEnvelope, null, 2) : "", + stderr: parsed.json ? "" : `error: ${diagnostic.message}` + }; + } + + if (parsed.mode === "show") { + const agent = envelope.agents.find(({ id }) => id === parsed.id); + if (!agent) { + const diagnostic: AgentSourceError = { + code: "agent-not-found", + message: "No normalized agent record matched the requested ID." + }; + const unknownEnvelope: AgentsEnvelope = { ...envelope, agents: [], error: diagnostic }; + return { + exitCode: 4, + stdout: parsed.json ? JSON.stringify(unknownEnvelope, null, 2) : "", + stderr: parsed.json ? "" : `error: ${diagnostic.message}` + }; + } + + const showEnvelope: AgentsEnvelope = { ...envelope, agents: [agent] }; + return { + exitCode: 0, + stdout: parsed.json ? JSON.stringify(showEnvelope, null, 2) : formatAgentDetails(agent, envelope.sources), + stderr: "" + }; + } + + const limitedEnvelope: AgentsEnvelope = { + ...envelope, + agents: envelope.agents.slice(0, parsed.limit) + }; + return { + exitCode: 0, + stdout: parsed.json ? JSON.stringify(limitedEnvelope, null, 2) : formatAgentsTable(limitedEnvelope), + stderr: "" + }; +} + +export function formatAgentsTable(envelope: AgentsEnvelope): string { + const rows = envelope.agents.map((agent) => [ + `${agent.active ? "active" : "inactive"}:${agent.status}`, + agent.id, + agent.worktree ?? "—", + formatTaskGoal(agent), + agent.last_tool_call.name ?? "—", + agent.freshness_at ?? "—" + ]); + const widths = [24, 36, 36, 30, 22, 24]; + const headers = ["STATUS", "PROVIDER/RUN", "WORKTREE", "TASK/GOAL", "LAST TOOL", "FRESHNESS"]; + const lines = [ + headers.map((value, index) => padCell(value, widths[index] ?? value.length)).join(" "), + rows + .map((row) => row.map((value, index) => padCell(value, widths[index] ?? value.length)).join(" ")) + .join("\n") + ].filter(Boolean); + + if (envelope.agents.length === 0) { + lines.push("No agents found."); + } + + const warnings = envelope.sources + .filter(({ status }) => status !== "ok") + .map((source) => `${source.provider}=${source.status}${source.error ? `:${source.error.code}` : ""}`); + if (warnings.length > 0) { + lines.push(`WARNING partial sources: ${warnings.join(", ")}`); + } + + return lines.join("\n"); +} + +export function dedupeAgents(agents: AgentRecord[]): AgentRecord[] { + const deduped = new Map(); + for (const agent of agents) { + const current = deduped.get(agent.id); + if (!current || compareFreshness(agent, current) < 0) { + deduped.set(agent.id, agent); + } + } + return [...deduped.values()]; +} + +export function sortAgents(agents: AgentRecord[]): AgentRecord[] { + return [...agents].sort(compareFreshness); +} + +function compareFreshness(left: AgentRecord, right: AgentRecord): number { + if (left.active !== right.active) { + return left.active ? -1 : 1; + } + const leftTime = Date.parse(left.updated_at ?? ""); + const rightTime = Date.parse(right.updated_at ?? ""); + const normalizedLeft = Number.isFinite(leftTime) ? leftTime : Number.NEGATIVE_INFINITY; + const normalizedRight = Number.isFinite(rightTime) ? rightTime : Number.NEGATIVE_INFINITY; + if (normalizedLeft !== normalizedRight) { + return normalizedRight - normalizedLeft; + } + return left.id.localeCompare(right.id); +} + +async function collectProvider( + command: ProviderCommand, + runner: ProviderCommandRunner, + generatedAt: string +): Promise { + let result: ProviderCommandResult; + try { + result = await runner(command); + } catch (error) { + return failedCollection(command.provider, "provider-execution-error", error, "error"); + } + + if (result.failure) { + const errorByFailure: Record< + NonNullable, + { code: string; message: string; status: AgentSourceStatus } + > = { + "not-found": { + code: "provider-command-unavailable", + message: `${command.provider} command is not installed or discoverable.`, + status: "unavailable" + }, + timeout: { + code: "provider-timeout", + message: `${command.provider} did not respond within the bounded timeout.`, + status: "error" + }, + "output-limit": { + code: "provider-output-limit", + message: `${command.provider} exceeded the bounded output limit.`, + status: "error" + }, + "spawn-error": { + code: "provider-spawn-error", + message: `Unable to execute the ${command.provider} read-only surface.`, + status: "error" + } + }; + const failure = errorByFailure[result.failure]; + return failedCollection(command.provider, failure.code, failure.message, failure.status); + } + + if (result.exitCode !== 0) { + const message = sanitizeDiagnostic(result.stderr) || `${command.provider} returned a nonzero exit.`; + return failedCollection(command.provider, "provider-nonzero-exit", message, "error"); + } + + let payload: unknown; + try { + payload = JSON.parse(result.stdout) as unknown; + } catch { + return failedCollection( + command.provider, + "provider-invalid-json", + `${command.provider} returned invalid JSON.`, + "error" + ); + } + + let normalized: NormalizeResult; + try { + normalized = normalizeProvider(command.provider, payload, generatedAt); + } catch { + return failedCollection( + command.provider, + "provider-invalid-payload", + `${command.provider} returned an unsupported JSON shape.`, + "error" + ); + } + + if (normalized.skipped > 0) { + return { + source: { + provider: command.provider, + status: "partial", + freshness_at: generatedAt, + error: { + code: "provider-records-skipped", + message: `${normalized.skipped} malformed provider record(s) were skipped.` + } + }, + agents: normalized.agents + }; + } + + return { + source: { + provider: command.provider, + status: "ok", + freshness_at: generatedAt + }, + agents: normalized.agents + }; +} + +function normalizeProvider( + provider: AgentProvider, + payload: unknown, + sourceFreshness: string +): NormalizeResult { + switch (provider) { + case "codewith": + return normalizeCodewith(payload, sourceFreshness); + case "claude": + return normalizeClaude(payload, sourceFreshness); + case "todos": + return normalizeTodos(payload, sourceFreshness); + } +} + +function normalizeCodewith(payload: unknown, sourceFreshness: string): NormalizeResult { + if (!isRecord(payload) || !Array.isArray(payload.data)) { + throw new Error("unsupported Codewith payload"); + } + const agents: AgentRecord[] = []; + let skipped = 0; + + for (const raw of payload.data.slice(0, MAX_AGENTS_LIMIT)) { + if (!isRecord(raw)) { + skipped += 1; + continue; + } + const runId = safeOpaqueId(raw.agentId); + if (!runId) { + skipped += 1; + continue; + } + const status = boundedText(raw.status, 64) ?? "unknown"; + const updatedAt = latestTimestamp(raw.updatedAt, raw.heartbeatAt); + const profileAlias = safeProfileAlias(raw.authProfileRef); + const gaps = [ + "worktree unavailable from Codewith list surface", + "branch unavailable from Codewith list surface", + "last_tool_call unavailable from Codewith list surface", + "goal unavailable from Codewith list surface", + "task unavailable from Codewith list surface" + ]; + if (status === "unknown") { + gaps.push("status unavailable from Codewith list surface"); + } + if (!updatedAt) { + gaps.push("updated_at unavailable from Codewith list surface"); + } + if (!profileAlias) { + gaps.push( + raw.authProfileRef == null + ? "profile unavailable from Codewith list surface" + : "profile reference withheld because it is not a safe alias" + ); + } + + agents.push({ + id: `codewith:${runId}`, + provider: "codewith", + run_id: runId, + status, + active: isActiveStatus(status), + started_at: normalizeTimestamp(raw.startedAt ?? raw.createdAt), + updated_at: updatedAt, + worktree: null, + branch: null, + last_tool_call: emptyLastToolCall(), + goal: emptyGoal(), + task: emptyTask(), + profile: { alias: profileAlias }, + freshness_at: updatedAt ?? sourceFreshness, + gaps + }); + } + + return { agents, skipped }; +} + +function normalizeClaude(payload: unknown, sourceFreshness: string): NormalizeResult { + if (!Array.isArray(payload)) { + throw new Error("unsupported Claude payload"); + } + const agents: AgentRecord[] = []; + let skipped = 0; + + for (const raw of payload.slice(0, MAX_AGENTS_LIMIT)) { + if (!isRecord(raw)) { + skipped += 1; + continue; + } + const runId = safeOpaqueId(raw.sessionId ?? raw.id); + if (!runId) { + skipped += 1; + continue; + } + const status = boundedText(raw.status ?? raw.state, 64) ?? "unknown"; + const worktree = safeAbsolutePath(raw.cwd); + const gaps = [ + "updated_at unavailable from Claude agents surface", + "branch unavailable from Claude agents surface", + "last_tool_call unavailable from Claude agents surface", + "goal unavailable from Claude agents surface", + "task unavailable from Claude agents surface", + "profile unavailable from Claude agents surface", + "agent freshness unavailable; using source observation time" + ]; + if (status === "unknown") { + gaps.push("status unavailable from Claude agents surface"); + } + if (!worktree) { + gaps.push("worktree unavailable from Claude agents surface"); + } + + agents.push({ + id: `claude:${runId}`, + provider: "claude", + run_id: runId, + status, + active: !isTerminalStatus(status), + started_at: normalizeTimestamp(raw.startedAt), + updated_at: null, + worktree, + branch: null, + last_tool_call: emptyLastToolCall(), + goal: emptyGoal(), + task: emptyTask(), + profile: { alias: null }, + freshness_at: sourceFreshness, + gaps + }); + } + + return { agents, skipped }; +} + +function normalizeTodos(payload: unknown, sourceFreshness: string): NormalizeResult { + if (!Array.isArray(payload)) { + throw new Error("unsupported Todos payload"); + } + const agents: AgentRecord[] = []; + let skipped = 0; + + for (const raw of payload.slice(0, MAX_AGENTS_LIMIT)) { + if (!isRecord(raw)) { + skipped += 1; + continue; + } + const runId = safeOpaqueId(raw.id); + if (!runId) { + skipped += 1; + continue; + } + const status = boundedText(raw.status, 64) ?? "unknown"; + const metadata = isRecord(raw.metadata) ? raw.metadata : {}; + const worktree = safeAbsolutePath(raw.working_dir ?? metadata.worktree); + const branch = safeBranch(metadata.branch); + const updatedAt = normalizeTimestamp(raw.updated_at ?? raw.synced_at); + const gaps = [ + "last_tool_call unavailable from Todos active surface", + "goal unavailable from Todos active surface", + "profile unavailable from Todos active surface" + ]; + if (status === "unknown") { + gaps.push("status unavailable from Todos active surface"); + } + if (!worktree) { + gaps.push("worktree unavailable from Todos active surface"); + } + if (!branch) { + gaps.push("branch unavailable from Todos active surface"); + } + if (!updatedAt) { + gaps.push("updated_at unavailable from Todos active surface"); + } + + agents.push({ + id: `todos:${runId}`, + provider: "todos", + run_id: runId, + status, + active: !isTerminalStatus(status), + started_at: normalizeTimestamp(raw.started_at ?? raw.created_at), + updated_at: updatedAt, + worktree, + branch, + last_tool_call: emptyLastToolCall(), + goal: emptyGoal(), + task: { + id: runId, + short_id: boundedText(raw.short_id, 40), + title: boundedText(raw.title, 120), + status + }, + profile: { alias: null }, + freshness_at: updatedAt ?? sourceFreshness, + gaps + }); + } + + return { agents, skipped }; +} + +function failedCollection( + provider: AgentProvider, + code: string, + message: unknown, + status: AgentSourceStatus +): ProviderCollection { + return { + source: { + provider, + status, + freshness_at: null, + error: makeDiagnostic(code, message) + }, + agents: [] + }; +} + +function errorEnvelope(generatedAt: string, error: AgentSourceError): AgentsEnvelope { + return { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: true, + sources: [], + agents: [], + error + }; +} + +function makeDiagnostic(code: string, value: unknown): AgentSourceError { + const raw = value instanceof Error ? value.message : String(value); + return { + code, + message: sanitizeDiagnostic(raw) || "Provider visibility failed without a safe diagnostic." + }; +} + +function sanitizeDiagnostic(value: unknown): string { + if (typeof value !== "string") { + return ""; + } + return bound( + redactVisibilitySecrets(value) + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]") + .replace(/\/(?:home|Users)\/[^/\s]+/g, "~") + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim(), + 200 + ); +} + +function boundedText(value: unknown, maximum: number): string | null { + if (typeof value !== "string") { + return null; + } + const sanitized = redactVisibilitySecrets(value) + .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]") + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return sanitized ? bound(sanitized, maximum) : null; +} + +function safeOpaqueId(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return /^[A-Za-z0-9._-]{1,256}$/.test(trimmed) && redactVisibilitySecrets(trimmed) === trimmed + ? trimmed + : null; +} + +function safeProfileAlias(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if ( + !/^[A-Za-z][A-Za-z0-9._-]{0,31}$/.test(trimmed) || + /^(?:sk|gh[pousr]|token|secret|key|auth|cred)[._-]/i.test(trimmed) || + /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(trimmed) || + redactVisibilitySecrets(trimmed) !== trimmed + ) { + return null; + } + return trimmed; +} + +function redactVisibilitySecrets(value: string): string { + return redactSensitiveText(value) + .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[REDACTED_GITHUB_TOKEN]") + .replace(/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_ANTHROPIC_KEY]") + .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[REDACTED_JWT]") + .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]") + .replace(/(https?:\/\/)[^/\s:@]+:[^@\s]+@/gi, "$1[REDACTED]@"); +} + +function safeAbsolutePath(value: unknown): string | null { + if (typeof value !== "string" || !isAbsolute(value) || value.length > 512) { + return null; + } + if ( + /(?:^|\/)\.(?:ssh|aws|config|codewith|claude)(?:\/|$)/i.test(value) || + /(?:^|\/)(?:secrets?|credentials?)(?:\/|$)/i.test(value) || + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(value) || + redactVisibilitySecrets(value) !== value || + /[\u0000-\u001f\u007f]/.test(value) + ) { + return null; + } + return value; +} + +function safeBranch(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return /^[A-Za-z0-9._/-]{1,200}$/.test(trimmed) && redactVisibilitySecrets(trimmed) === trimmed + ? trimmed + : null; +} + +function normalizeTimestamp(value: unknown): string | null { + let milliseconds: number; + if (typeof value === "number" && Number.isFinite(value)) { + milliseconds = value > 10_000_000_000 ? value : value * 1000; + } else if (typeof value === "string" && value.trim()) { + const numeric = Number(value); + milliseconds = Number.isFinite(numeric) + ? numeric > 10_000_000_000 + ? numeric + : numeric * 1000 + : Date.parse(value); + } else { + return null; + } + if (!Number.isFinite(milliseconds)) { + return null; + } + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function latestTimestamp(...values: unknown[]): string | null { + const timestamps = values + .map(normalizeTimestamp) + .filter((value): value is string => value !== null) + .sort((left, right) => Date.parse(right) - Date.parse(left)); + return timestamps[0] ?? null; +} + +function isTerminalStatus(status: string): boolean { + return /^(?:cancelled|completed|done|failed|stopped|terminated|archived|succeeded)$/i.test(status); +} + +function isActiveStatus(status: string): boolean { + return !isTerminalStatus(status); +} + +function emptyLastToolCall(): AgentRecord["last_tool_call"] { + return { name: null, at: null, summary: null }; +} + +function emptyGoal(): AgentRecord["goal"] { + return { id: null, title: null, status: null }; +} + +function emptyTask(): AgentRecord["task"] { + return { id: null, short_id: null, title: null, status: null }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function bound(value: string, maximum: number): string { + if (value.length <= maximum) { + return value; + } + return `${value.slice(0, Math.max(0, maximum - 1))}…`; +} + +function parseAgentsArguments(args: string[]): ParsedAgentsArguments { + let json = false; + let limit = DEFAULT_AGENTS_LIMIT; + const positionals: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index] ?? ""; + if (argument === "--json") { + json = true; + continue; + } + if (argument === "--limit") { + const value = args[index + 1]; + if (!value) { + throw new Error("--limit requires an integer."); + } + limit = parseLimit(value); + index += 1; + continue; + } + if (argument.startsWith("--limit=")) { + limit = parseLimit(argument.slice("--limit=".length)); + continue; + } + if (argument.startsWith("-")) { + throw new Error(`Unknown option: ${argument}`); + } + positionals.push(argument); + } + + if (positionals.length === 0 || (positionals.length === 1 && positionals[0] === "list")) { + return { json, limit, mode: "list", id: null }; + } + if (positionals[0] === "show" && positionals.length === 2) { + return { json, limit, mode: "show", id: positionals[1] ?? null }; + } + throw new Error("Usage: tai agents [--json] [--limit <1-200>] | tai agents show : [--json]"); +} + +function parseLimit(value: string): number { + if (!/^\d+$/.test(value)) { + throw new Error("--limit must be an integer between 1 and 200."); + } + const parsed = Number(value); + if (parsed < 1 || parsed > MAX_AGENTS_LIMIT) { + throw new Error("--limit must be between 1 and 200."); + } + return parsed; +} + +function isValidAgentId(value: string | null): value is string { + if (!value) { + return false; + } + const separator = value.indexOf(":"); + if (separator <= 0 || separator === value.length - 1) { + return false; + } + const provider = value.slice(0, separator); + const runId = value.slice(separator + 1); + return ( + (provider === "codewith" || provider === "claude" || provider === "todos") && + safeOpaqueId(runId) === runId + ); +} + +function formatTaskGoal(agent: AgentRecord): string { + if (agent.task.id) { + return bound(`${agent.task.short_id ?? agent.task.id} ${agent.task.title ?? ""}`.trim(), 30); + } + if (agent.goal.id) { + return bound(`${agent.goal.id} ${agent.goal.title ?? ""}`.trim(), 30); + } + return "—"; +} + +function formatAgentDetails(agent: AgentRecord, sources: AgentSource[]): string { + const sourceLines = sources.map( + (source) => + ` ${source.provider}: ${source.status}${source.error ? ` (${source.error.code})` : ""}` + ); + return [ + `Agent: ${agent.id}`, + `Status: ${agent.status} (${agent.active ? "active" : "inactive"})`, + `Started: ${agent.started_at ?? "—"}`, + `Updated: ${agent.updated_at ?? "—"}`, + `Worktree: ${agent.worktree ?? "—"}`, + `Branch: ${agent.branch ?? "—"}`, + `Task: ${agent.task.id ? `${agent.task.short_id ?? agent.task.id} ${agent.task.title ?? ""}`.trim() : "—"}`, + `Goal: ${agent.goal.id ? `${agent.goal.id} ${agent.goal.title ?? ""}`.trim() : "—"}`, + `Last tool: ${agent.last_tool_call.name ?? "—"}`, + `Profile: ${agent.profile.alias ?? "—"}`, + `Freshness: ${agent.freshness_at ?? "—"}`, + `Gaps: ${agent.gaps.length > 0 ? agent.gaps.join("; ") : "none"}`, + "Sources:", + ...sourceLines + ].join("\n"); +} + +function padCell(value: string, width: number): string { + return bound(value, width).padEnd(width, " "); +} + +async function runProviderCommand(command: ProviderCommand): Promise { + return await new Promise((resolve) => { + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let failure: ProviderCommandResult["failure"]; + let resolved = false; + const child = spawn(command.command, command.args, { + shell: false, + stdio: ["ignore", "pipe", "pipe"] + }); + + const finish = (exitCode: number | null): void => { + if (resolved) { + return; + } + resolved = true; + clearTimeout(timer); + resolve({ + stdout: stdout.toString("utf8"), + stderr: stderr.toString("utf8"), + exitCode, + ...(failure ? { failure } : {}) + }); + }; + + const timer = setTimeout(() => { + failure = "timeout"; + child.kill("SIGKILL"); + }, PROVIDER_TIMEOUT_MS); + + child.stdout.on("data", (chunk: Buffer) => { + if (stdout.length + chunk.length > MAX_STDOUT_BYTES) { + failure = "output-limit"; + child.kill("SIGKILL"); + return; + } + stdout = Buffer.concat([stdout, chunk]); + }); + + child.stderr.on("data", (chunk: Buffer) => { + const remaining = MAX_STDERR_BYTES - stderr.length; + if (remaining > 0) { + stderr = Buffer.concat([stderr, chunk.subarray(0, remaining)]); + } + }); + + child.on("error", (error: NodeJS.ErrnoException) => { + failure = error.code === "ENOENT" ? "not-found" : "spawn-error"; + finish(null); + }); + child.on("close", (exitCode) => finish(exitCode)); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 1c8d6b7..1708ac5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createTai } from "../sdk"; import { formatAgenticPlan } from "../agentic-plan"; +import { runAgentsCli } from "../agents"; import { formatCommandPreview } from "../proposal"; import { classifyCommand } from "../safety"; import { runShellCommand } from "../shell"; @@ -33,6 +34,17 @@ async function main(): Promise { return plan.blocked ? 2 : 0; } + if (command === "agents") { + const result = await runAgentsCli(args); + if (result.stdout) { + console.log(result.stdout); + } + if (result.stderr) { + console.error(result.stderr); + } + return result.exitCode; + } + if (command === "run") { const yesIndex = args.indexOf("--yes"); const overrideIndex = args.indexOf("--override"); @@ -65,6 +77,8 @@ function printHelp(): void { Usage: tai propose tai plan + tai agents [--json] [--limit <1-200>] + tai agents show : [--json] tai classify tai run [--yes] [--override] `); diff --git a/tests/agents.test.ts b/tests/agents.test.ts new file mode 100644 index 0000000..a98d561 --- /dev/null +++ b/tests/agents.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENTS_SCHEMA_VERSION, + collectAgentVisibility, + formatAgentsTable, + runAgentsCli, + type ProviderCommand, + type ProviderCommandResult, + type ProviderCommandRunner +} from "../src/agents"; + +const NOW = new Date("2026-07-23T12:00:00.000Z"); +const SECRET_LIKE_GITHUB_TOKEN = ["ghp", "1234567890abcdefghijklmnopqrst"].join("_"); + +function result(payload: unknown): ProviderCommandResult { + return { + stdout: JSON.stringify(payload), + stderr: "", + exitCode: 0 + }; +} + +function fixtures(): Record { + return { + codewith: result({ + data: [ + { + agentId: "cw-old", + status: "running", + startedAt: 1_753_200_000, + updatedAt: 1_753_200_100, + authProfileRef: "account001", + statusReason: "raw transcript and sk-1234567890abcdef" + }, + { + agentId: "cw-old", + status: "running", + startedAt: 1_753_200_000, + updatedAt: 1_753_200_200, + authProfileRef: "sk-secret-profile-reference" + }, + { + agentId: "cw-complete", + status: "completed", + startedAt: 1_753_100_000, + updatedAt: 1_753_100_100 + } + ] + }), + claude: result([ + { + sessionId: "claude-new", + status: "busy", + startedAt: 1_753_300_000_000, + cwd: "/workspace/repo" + } + ]), + todos: result([ + { + id: "task-newest", + short_id: "E-00104", + title: `Visible task ${SECRET_LIKE_GITHUB_TOKEN} ${"x".repeat(180)}`, + status: "in_progress", + started_at: "2026-07-23T10:00:00.000Z", + updated_at: "2026-07-23T11:59:00.000Z", + working_dir: "/workspace/tai", + metadata: { branch: "feat/agents" }, + description: "full prompt must never be projected" + } + ]) + }; +} + +function fakeRunner( + overrides: Partial> = {} +): { runner: ProviderCommandRunner; calls: ProviderCommand[] } { + const calls: ProviderCommand[] = []; + const responses = { ...fixtures(), ...overrides }; + return { + calls, + runner: async (command) => { + calls.push(command); + return responses[command.provider]; + } + }; +} + +describe("agent visibility normalization", () => { + test("normalizes, redacts, bounds, deduplicates, and sorts provider records", async () => { + const { runner } = fakeRunner(); + const envelope = await collectAgentVisibility({ runner, now: () => NOW }); + + expect(envelope.schema_version).toBe(AGENTS_SCHEMA_VERSION); + expect(envelope.generated_at).toBe(NOW.toISOString()); + expect(envelope.partial).toBe(false); + expect(envelope.sources.map(({ status }) => status)).toEqual(["ok", "ok", "ok"]); + expect(envelope.agents).toHaveLength(4); + expect(envelope.agents[0]?.id).toBe("todos:task-newest"); + expect(envelope.agents.at(-1)?.id).toBe("codewith:cw-complete"); + expect(envelope.agents.filter(({ id }) => id === "codewith:cw-old")).toHaveLength(1); + + const codewith = envelope.agents.find(({ id }) => id === "codewith:cw-old"); + expect(codewith?.updated_at).toBe("2025-07-22T16:03:20.000Z"); + expect(codewith?.profile.alias).toBeNull(); + expect(codewith?.last_tool_call).toEqual({ name: null, at: null, summary: null }); + expect(codewith?.gaps).toContain("last_tool_call unavailable from Codewith list surface"); + expect(codewith?.gaps).toContain("profile reference withheld because it is not a safe alias"); + + const todos = envelope.agents.find(({ provider }) => provider === "todos"); + expect(todos?.task.title).toContain("[REDACTED_"); + expect(todos?.task.title).not.toContain(SECRET_LIKE_GITHUB_TOKEN); + expect(todos?.task.title?.length).toBeLessThanOrEqual(120); + expect(JSON.stringify(envelope)).not.toContain("full prompt"); + expect(JSON.stringify(envelope)).not.toContain("raw transcript"); + expect(JSON.stringify(envelope)).not.toContain("sk-secret-profile-reference"); + }); + + test("marks one failed source partial and retains available records", async () => { + const { runner } = fakeRunner({ + claude: { + stdout: "", + stderr: "", + exitCode: null, + failure: "not-found" + } + }); + const envelope = await collectAgentVisibility({ runner, now: () => NOW }); + + expect(envelope.partial).toBe(true); + expect(envelope.sources.find(({ provider }) => provider === "claude")).toEqual({ + provider: "claude", + status: "unavailable", + freshness_at: null, + error: { + code: "provider-command-unavailable", + message: "claude command is not installed or discoverable." + } + }); + expect(envelope.agents.some(({ provider }) => provider === "codewith")).toBe(true); + expect(envelope.agents.some(({ provider }) => provider === "todos")).toBe(true); + }); + + test("withholds secret-like identifiers and sensitive configuration paths", async () => { + const secretId = SECRET_LIKE_GITHUB_TOKEN; + const { runner } = fakeRunner({ + codewith: result({ + data: [ + { agentId: secretId, status: "running" }, + { agentId: "safe-run", status: "running" } + ] + }), + claude: result([ + { + sessionId: "claude-safe", + status: "idle", + cwd: "/home/operator/.codewith/auth" + } + ]), + todos: result([]) + }); + const envelope = await collectAgentVisibility({ runner, now: () => NOW }); + + expect(envelope.sources.find(({ provider }) => provider === "codewith")?.status).toBe("partial"); + expect(envelope.agents.some(({ run_id }) => run_id === secretId)).toBe(false); + const claude = envelope.agents.find(({ id }) => id === "claude:claude-safe"); + expect(claude?.worktree).toBeNull(); + expect(claude?.gaps).toContain("worktree unavailable from Claude agents surface"); + expect(JSON.stringify(envelope)).not.toContain(".codewith/auth"); + expect(JSON.stringify(envelope)).not.toContain(secretId); + }); + + test("reports bounded-output failures without exposing captured provider text", async () => { + const { runner } = fakeRunner({ + todos: { + stdout: "raw transcript should be discarded", + stderr: "Authorization: Bearer should-not-leak", + exitCode: null, + failure: "output-limit" + } + }); + const envelope = await collectAgentVisibility({ runner, now: () => NOW }); + const source = envelope.sources.find(({ provider }) => provider === "todos"); + + expect(source?.status).toBe("error"); + expect(source?.error?.code).toBe("provider-output-limit"); + expect(JSON.stringify(envelope)).not.toContain("raw transcript"); + expect(JSON.stringify(envelope)).not.toContain("should-not-leak"); + }); + + test("uses exactly one bounded command per provider and never does per-record calls", async () => { + const { runner, calls } = fakeRunner(); + await collectAgentVisibility({ runner, now: () => NOW }); + + expect(calls).toHaveLength(3); + expect(calls.map(({ provider }) => provider)).toEqual(["codewith", "claude", "todos"]); + expect(calls.find(({ provider }) => provider === "codewith")?.args).toEqual([ + "agent", + "list", + "--json", + "--limit", + "200" + ]); + expect(calls.find(({ provider }) => provider === "claude")?.args).toEqual(["agents", "--json"]); + expect(calls.find(({ provider }) => provider === "todos")?.args).toEqual(["active", "--json"]); + }); +}); + +describe("agent visibility CLI", () => { + test("emits the stable JSON schema shape", async () => { + const { runner } = fakeRunner(); + const response = await runAgentsCli(["--json", "--limit", "2"], { runner, now: () => NOW }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(0); + expect(Object.keys(envelope).sort()).toEqual([ + "agents", + "generated_at", + "partial", + "schema_version", + "sources" + ]); + expect(envelope.agents).toHaveLength(2); + expect(Object.keys(envelope.agents[0]).sort()).toEqual([ + "active", + "branch", + "freshness_at", + "gaps", + "goal", + "id", + "last_tool_call", + "profile", + "provider", + "run_id", + "started_at", + "status", + "task", + "updated_at", + "worktree" + ]); + expect(Object.keys(envelope.sources[0]).sort()).toEqual([ + "freshness_at", + "provider", + "status" + ]); + }); + + test("renders a compact human table and explicit partial warning", async () => { + const { runner } = fakeRunner({ + claude: { + stdout: "", + stderr: "Authorization: Bearer should-not-leak", + exitCode: 1 + } + }); + const response = await runAgentsCli([], { runner, now: () => NOW }); + + expect(response.exitCode).toBe(0); + expect(response.stdout).toContain("STATUS"); + expect(response.stdout).toContain("PROVIDER/RUN"); + expect(response.stdout).toContain("TASK/GOAL"); + expect(response.stdout).toContain("WARNING partial sources: claude=error:provider-nonzero-exit"); + expect(response.stdout).not.toContain("should-not-leak"); + expect(formatAgentsTable(JSON.parse((await runAgentsCli(["--json"], { runner, now: () => NOW })).stdout))).toContain( + "WARNING partial sources" + ); + }); + + test("returns a machine-readable malformed ID error without provider execution", async () => { + const { runner, calls } = fakeRunner(); + const response = await runAgentsCli(["show", "bad", "--json"], { runner, now: () => NOW }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(2); + expect(envelope.error.code).toBe("invalid-agent-id"); + expect(envelope.agents).toEqual([]); + expect(calls).toHaveLength(0); + }); + + test("returns a stable unknown ID error after one call to each source", async () => { + const { runner, calls } = fakeRunner(); + const response = await runAgentsCli(["show", "codewith:missing", "--json"], { + runner, + now: () => NOW + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(4); + expect(envelope.error.code).toBe("agent-not-found"); + expect(calls).toHaveLength(3); + expect(calls.flatMap(({ args }) => args)).not.toContain("missing"); + }); + + test("returns nonzero when every provider fails", async () => { + const failure: ProviderCommandResult = { + stdout: "", + stderr: "provider unavailable", + exitCode: 1 + }; + const { runner } = fakeRunner({ + codewith: failure, + claude: failure, + todos: failure + }); + const response = await runAgentsCli(["--json"], { runner, now: () => NOW }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(3); + expect(envelope.error.code).toBe("all-sources-failed"); + expect(envelope.partial).toBe(true); + expect(envelope.sources.every(({ status }: { status: string }) => status === "error")).toBe(true); + }); + + test("rejects limits above the hard maximum", async () => { + const { runner, calls } = fakeRunner(); + const response = await runAgentsCli(["--limit", "201", "--json"], { runner, now: () => NOW }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(2); + expect(envelope.error.code).toBe("invalid-arguments"); + expect(calls).toHaveLength(0); + }); + + test("applies the default result limit of 50", async () => { + const manyTasks = Array.from({ length: 60 }, (_, index) => ({ + id: `task-${String(index).padStart(3, "0")}`, + title: `Task ${index}`, + status: "in_progress", + updated_at: new Date(NOW.getTime() - index * 1000).toISOString() + })); + const { runner } = fakeRunner({ + codewith: result({ data: [] }), + claude: result([]), + todos: result(manyTasks) + }); + const response = await runAgentsCli(["--json"], { runner, now: () => NOW }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(0); + expect(envelope.agents).toHaveLength(50); + }); +}); From 8b91c8deaa9289b216561ed192069fe141bc23e2 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 19:08:47 +0300 Subject: [PATCH 2/3] fix(cli): harden agent visibility boundaries --- README.md | 18 +- docs/architecture.md | 12 +- src/agents.ts | 1579 ++++++++++++++++++++++++++---------------- tests/agents.test.ts | 992 +++++++++++++++++++------- 4 files changed, 1726 insertions(+), 875 deletions(-) diff --git a/README.md b/README.md index 7785e93..208a8d5 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ tai propose "show the largest files in this repo" tai plan "inspect the repo status, run the relevant checks, and show me the safe next commands" tai agents tai agents --json -tai agents show codewith: +tai agents show todos: tai classify "rm -rf dist" tai run "ls -la" --yes ``` @@ -36,19 +36,25 @@ tai run "ls -la" --yes ### Headless agent visibility -`tai agents [--limit <1-200>]` is a local, stateless, read-only projection of the installed Codewith, Claude, and Todos agent surfaces. The default limit is 50. `tai agents show :` selects one exact normalized record. Add `--json` to either form for the versioned machine contract. +`tai agents [--limit <1-200>]` is a stateless, read-only projection of independently safe provider surfaces. The default output limit is 50. `tai agents show :` performs an exact lookup against only the selected provider. Add `--json` to either form for the versioned machine contract. `--limit` is rejected for exact lookup. -Each source is invoked at most once per command. Missing providers fail soft: available records are still returned with `partial: true` and bounded source diagnostics. If every provider fails, the command exits nonzero. The command does not persist data, inspect transcripts or prompts, call a provider once per record, switch profiles, or own agent lifecycle state. +TAI does not invoke the installed `codewith agent list`, `claude agents`, or `todos active` routes: those routes can create configuration or database files, change permissions, or clean expired locks. Codewith and Claude therefore report `side-effect-free-surface-unavailable` until those providers expose a safe structured read API. Todos list reports `side-effect-free-source-limit-unavailable`: its task-list implementation cleans expired locks, while its agent-list API has no authoritative source limit. TAI will not capture either stream. + +When `TODOS_URL` points to an HTTPS API (or an HTTP loopback API), exact Todos lookup can use the side-effect-free `GET /v1/tasks/` resource. `TODOS_API_KEY` is used only as a request header and is never projected. Without a safe list surface, `tai agents` currently returns a structured unavailable envelope and nonzero exit; it does not pretend empty or complete coverage. + +Missing providers fail soft while any independently safe source responds. If every source is unavailable or errors, the command exits nonzero. Exact lookup returns `agent-not-found` only after a complete targeted 404; unavailable, truncated, unproven, or incomplete evidence returns `agent-lookup-incomplete`. A Todos task is projected only when it contains authoritative agent, session, runner, or live-lease provenance. Assignment or `in_progress` status alone is insufficient. JSON v1 has these stable fields: - Envelope: `schema_version` (number, currently `1`), `generated_at` (ISO-8601 string), `partial` (boolean), `sources` (source diagnostics), and `agents` (normalized records). Command errors add a bounded `error` object. -- Source diagnostic: `provider` (`codewith|claude|todos`), `status` (`ok|partial|unavailable|error`), `freshness_at` (ISO-8601 string or `null`), and optional `error: {code, message}`. +- Source diagnostic: `provider` (`codewith|claude|todos`), `status` (`ok|partial|unavailable|error`), `freshness_at` (the bounded source-operation completion time or `null`), `coverage: {complete, provider_records, projected_records, dropped_records}`, and optional `error: {code, message}`. - Agent identity/state: `id`, `provider`, `run_id`, `status`, `active`, `started_at`, `updated_at`, `freshness_at`. - Agent context: `worktree`, `branch`, `last_tool_call: {name, at, summary}`, `goal: {id, title, status}`, `task: {id, short_id, title, status}`, and `profile: {alias}`. -- `gaps` is an array of explicit missing or stale normalized fields. Unavailable provider fields remain `null`; they are never inferred from prompt, transcript, or matching text. +- `gaps` is a sorted array of stable field names for every missing normalized value. Unavailable or withheld fields remain `null`; they are never inferred from prompts, transcripts, paths, matching text, or task assignment. + +Output is allowlisted rather than denylist-redacted. Run and task IDs must be canonical UUIDs, task short IDs use a narrow `E-00104`-style form, statuses come from a closed set, and profile aliases use the configured `accountNNN` form. Provider-controlled titles, paths, branches, last-tool text, goal text, raw diagnostics, account IDs, credential labels or values, signed URI components, query or fragment values, and nonprinting Unicode controls are omitted. Human cells are truncated by grapheme cluster. -Text fields and diagnostics are bounded and redacted. Profile output accepts only a safe configured alias such as `account001`; it never includes email, account ID, auth path, token, or credential metadata. Provider command output is captured under a hard byte and time limit, and raw transcripts, full prompts, full tool arguments, and environment dumps are not projected. +Exact Todos responses are bounded to 1 MiB and five seconds. List coverage remains explicitly unknown until the provider exposes a side-effect-free source limit. Future safe list sources must use the default output limit of 50 and hard maximum of 200, and any source or result truncation must set `partial: true` with known or unknown dropped coverage. TAI never persists agent data, initializes a provider, mutates locks or tasks, inspects transcripts or prompts, calls a provider once per result, switches profiles, or owns agent lifecycle state. ## Provider Routing diff --git a/docs/architecture.md b/docs/architecture.md index dbabac9..a4507d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,9 +43,17 @@ Model IDs are intentionally configuration-driven because availability is time-se ## Agent Visibility Facade -The `tai agents` surface is separate from model routing. It is a stateless, read-only facade over the installed Codewith background-agent list, Claude agent list, and Todos active-task command surfaces. A request makes one bounded, non-shell provider call per source and projects the returned batch locally; it never performs a provider read for each result. +The `tai agents` surface is separate from model routing. It is a stateless, read-only facade over provider-owned structured APIs that are demonstrably side-effect free. It does not invoke installed Codewith, Claude, or Todos list commands because those routes can initialize state, change permissions, create databases, or mutate expired task locks. -The normalized schema records only bounded status/context fields exposed by those list surfaces. Missing worktree, branch, tool, goal, task, profile, timestamp, or freshness data stays `null` and is named in `gaps`. Source failures are isolated and diagnosed with stable codes. TAI owns no database, daemon, cache, task assignment, authorization, or agent lifecycle state, and this surface contains no stop, retry, resume, profile-switch, notification, fleet, web, or TUI behavior. +Codewith and Claude remain explicit unavailable sources until they expose a safe structured read surface. Todos list is also unavailable: its task-list implementation performs expired-lock cleanup, and its agent-list API has no authoritative source-level limit. A configured Todos API may perform one exact task `GET`, with a one-MiB response cap and a five-second wall-clock bound. Exact show calls only its selected provider. Absence is reported only after a complete targeted 404; unavailable, truncated, or unproven results remain incomplete. + +Normalization is omission-first. Only canonical UUIDs, narrow task short IDs, closed-set statuses, ISO timestamps within bounded clock skew, and `accountNNN` profile aliases may cross the output boundary. Provider-controlled titles, paths, branch names, tool text, goal text, raw diagnostics, URI credentials, query/fragment values, account identifiers, and nonprinting Unicode controls never do. Every null field has a stable named gap, and every source reports explicit complete/returned/dropped coverage. + +Todos tasks require authoritative agent, session, runner, or live-lease provenance before projection. Assignment or task status does not establish an agent. Duplicate state is selected by newest valid observation first, then explicit status precedence and a canonical tie-breaker; unknown status and invalid or future timestamps fail closed. + +The reusable process boundary used by provider integrations creates a process group and force-resolves on deadline or stdout/stderr byte overflow, including when descendants retain pipes. Current agent visibility uses no provider process because no installed command meets the side-effect-free contract. + +TAI owns no database, daemon, cache, task assignment, authorization, WorkRun, or agent lifecycle state, and this surface contains no stop, retry, resume, profile-switch, notification, fleet, web, or TUI behavior. ## Safety Policy diff --git a/src/agents.ts b/src/agents.ts index e7a6051..67aa93e 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,16 +1,33 @@ import { spawn } from "node:child_process"; -import { isAbsolute } from "node:path"; -import { redactSensitiveText } from "./redaction"; export const AGENTS_SCHEMA_VERSION = 1 as const; export const DEFAULT_AGENTS_LIMIT = 50; export const MAX_AGENTS_LIMIT = 200; -const MAX_STDOUT_BYTES = 4 * 1024 * 1024; -const MAX_STDERR_BYTES = 16 * 1024; -const PROVIDER_TIMEOUT_MS = 15_000; - -export type AgentProvider = "codewith" | "claude" | "todos"; +const DEFAULT_HTTP_TIMEOUT_MS = 5_000; +const DEFAULT_HTTP_BYTES = 1024 * 1024; +const DEFAULT_PROCESS_TIMEOUT_MS = 15_000; +const DEFAULT_PROCESS_STDOUT_BYTES = 4 * 1024 * 1024; +const DEFAULT_PROCESS_STDERR_BYTES = 16 * 1024; +const EARLIEST_VALID_TIME_MS = Date.parse("2000-01-01T00:00:00.000Z"); + +const PROVIDERS = ["codewith", "claude", "todos"] as const; +const ACTIVE_STATUSES = new Set(["idle", "in_progress", "running"]); +const STATUS_PRECEDENCE: Readonly> = { + running: 80, + in_progress: 70, + idle: 60, + blocked: 50, + pending: 40, + succeeded: 30, + completed: 30, + failed: 20, + stopped: 10, + cancelled: 10, + unknown: 0 +}; + +export type AgentProvider = (typeof PROVIDERS)[number]; export type AgentSourceStatus = "ok" | "partial" | "unavailable" | "error"; export interface AgentSourceError { @@ -18,10 +35,18 @@ export interface AgentSourceError { message: string; } +export interface AgentSourceCoverage { + complete: boolean; + provider_records: number | null; + projected_records: number; + dropped_records: number | null; +} + export interface AgentSource { provider: AgentProvider; status: AgentSourceStatus; freshness_at: string | null; + coverage: AgentSourceCoverage; error?: AgentSourceError; } @@ -67,23 +92,44 @@ export interface AgentsEnvelope { error?: AgentSourceError; } -export interface ProviderCommand { +export interface ProviderHttpRequest { provider: AgentProvider; + method: "GET"; + url: string; + headers: Readonly>; + timeoutMs: number; + maxBytes: number; +} + +export interface ProviderHttpResult { + status: number | null; + body: string; + failure?: "network-error" | "output-limit" | "timeout"; +} + +export type ProviderHttpRunner = (request: ProviderHttpRequest) => Promise; + +export interface ProviderProcessRequest { command: string; args: string[]; + timeoutMs?: number; + maxStdoutBytes?: number; + maxStderrBytes?: number; + cwd?: string; + env?: NodeJS.ProcessEnv; } -export interface ProviderCommandResult { +export interface ProviderProcessResult { stdout: string; stderr: string; exitCode: number | null; - failure?: "not-found" | "timeout" | "output-limit" | "spawn-error"; + failure?: "not-found" | "output-limit" | "spawn-error" | "timeout"; } -export type ProviderCommandRunner = (command: ProviderCommand) => Promise; - export interface CollectAgentsOptions { - runner?: ProviderCommandRunner; + env?: Readonly>; + httpRunner?: ProviderHttpRunner; + limit?: number; now?: () => Date; } @@ -93,56 +139,50 @@ export interface AgentsCliResult { stderr: string; } +interface ParsedAgentsArguments { + json: boolean; + limit: number; + limitSpecified: boolean; + mode: "list" | "show"; + id: string | null; +} + interface ProviderCollection { source: AgentSource; agents: AgentRecord[]; } -interface NormalizeResult { - agents: AgentRecord[]; - skipped: number; +interface TodosConfig { + baseUrl: URL; + apiKey: string | null; } -interface ParsedAgentsArguments { - json: boolean; - limit: number; - mode: "list" | "show"; - id: string | null; +interface NormalizedStatus { + value: string; + active: boolean; + complete: boolean; } -const PROVIDER_COMMANDS: ProviderCommand[] = [ - { - provider: "codewith", - command: "codewith", - args: ["agent", "list", "--json", "--limit", String(MAX_AGENTS_LIMIT)] - }, - { - provider: "claude", - command: "claude", - args: ["agents", "--json"] - }, - { - provider: "todos", - command: "todos", - args: ["active", "--json"] - } -]; - -export async function collectAgentVisibility(options: CollectAgentsOptions = {}): Promise { - const runner = options.runner ?? runProviderCommand; - const generatedAt = (options.now ?? (() => new Date()))().toISOString(); +export async function collectAgentVisibility( + options: CollectAgentsOptions = {} +): Promise { + const now = options.now ?? (() => new Date()); + const generatedAt = safeNow(now); + const limit = validateLimit(options.limit ?? DEFAULT_AGENTS_LIMIT); const collections = await Promise.all( - PROVIDER_COMMANDS.map((command) => collectProvider(command, runner, generatedAt)) + PROVIDERS.map((provider) => collectProvider(provider, null, options, now)) ); const sources = collections.map(({ source }) => source); - const agents = sortAgents(dedupeAgents(collections.flatMap((collection) => collection.agents))); + const allAgents = sortAgents(dedupeAgents(collections.flatMap(({ agents }) => agents))); + const visibleAgents = allAgents.slice(0, limit); + applyResultLimitCoverage(sources, allAgents, visibleAgents); return { schema_version: AGENTS_SCHEMA_VERSION, generated_at: generatedAt, - partial: sources.some(({ status }) => status !== "ok"), + partial: sources.some(({ status, coverage }) => status !== "ok" || !coverage.complete), sources, - agents + agents: visibleAgents }; } @@ -150,112 +190,159 @@ export async function runAgentsCli( args: string[], options: CollectAgentsOptions = {} ): Promise { - const generatedAt = (options.now ?? (() => new Date()))().toISOString(); + const now = options.now ?? (() => new Date()); + const generatedAt = safeNow(now); let parsed: ParsedAgentsArguments; try { parsed = parseAgentsArguments(args); - } catch (error) { - const diagnostic = makeDiagnostic("invalid-arguments", error); - const json = args.includes("--json"); - return { - exitCode: 2, - stdout: json ? JSON.stringify(errorEnvelope(generatedAt, diagnostic), null, 2) : "", - stderr: json ? "" : `error: ${diagnostic.message}` - }; + } catch { + return cliError( + 2, + args.includes("--json"), + errorEnvelope(generatedAt, { + code: "invalid-arguments", + message: + "Usage: tai agents [--json] [--limit <1-200>] | tai agents show : [--json]." + }) + ); } - if (parsed.mode === "show" && !isValidAgentId(parsed.id)) { - const diagnostic: AgentSourceError = { - code: "invalid-agent-id", - message: "Agent ID must use :." - }; - return { - exitCode: 2, - stdout: parsed.json ? JSON.stringify(errorEnvelope(generatedAt, diagnostic), null, 2) : "", - stderr: parsed.json ? "" : `error: ${diagnostic.message}` - }; + const parsedId = parsed.mode === "show" ? parseAgentId(parsed.id) : null; + if (parsed.mode === "show" && !parsedId) { + return cliError( + 2, + parsed.json, + errorEnvelope(generatedAt, { + code: "invalid-agent-id", + message: "Agent ID must use :." + }) + ); } - const envelope = await collectAgentVisibility(options); - const availableSources = envelope.sources.filter( - ({ status }) => status === "ok" || status === "partial" - ); - - if (availableSources.length === 0) { - const diagnostic: AgentSourceError = { - code: "all-sources-failed", - message: "No authoritative agent source is currently available." - }; - const failedEnvelope = { ...envelope, error: diagnostic }; - return { - exitCode: 3, - stdout: parsed.json ? JSON.stringify(failedEnvelope, null, 2) : "", - stderr: parsed.json ? "" : `error: ${diagnostic.message}` - }; + if (parsed.mode === "show" && parsed.limitSpecified) { + return cliError( + 2, + parsed.json, + errorEnvelope(generatedAt, { + code: "invalid-arguments", + message: "--limit is not valid for an exact agent lookup." + }) + ); } - if (parsed.mode === "show") { + if (parsed.mode === "show" && parsedId) { + const collection = await collectProvider(parsedId.provider, parsedId.runId, options, now); + const envelope: AgentsEnvelope = { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: + collection.source.status !== "ok" || !collection.source.coverage.complete, + sources: [collection.source], + agents: sortAgents(dedupeAgents(collection.agents)) + }; const agent = envelope.agents.find(({ id }) => id === parsed.id); - if (!agent) { - const diagnostic: AgentSourceError = { - code: "agent-not-found", - message: "No normalized agent record matched the requested ID." - }; - const unknownEnvelope: AgentsEnvelope = { ...envelope, agents: [], error: diagnostic }; + + if (agent) { + envelope.agents = [agent]; return { - exitCode: 4, - stdout: parsed.json ? JSON.stringify(unknownEnvelope, null, 2) : "", - stderr: parsed.json ? "" : `error: ${diagnostic.message}` + exitCode: 0, + stdout: parsed.json + ? JSON.stringify(envelope, null, 2) + : formatAgentDetails(agent, envelope.sources), + stderr: "" }; } - const showEnvelope: AgentsEnvelope = { ...envelope, agents: [agent] }; - return { - exitCode: 0, - stdout: parsed.json ? JSON.stringify(showEnvelope, null, 2) : formatAgentDetails(agent, envelope.sources), - stderr: "" + if ( + collection.source.status === "ok" && + collection.source.coverage.complete && + collection.source.coverage.dropped_records === 0 + ) { + envelope.error = { + code: "agent-not-found", + message: "The selected provider proved that no exact agent record exists." + }; + return cliError(4, parsed.json, envelope); + } + + envelope.error = { + code: "agent-lookup-incomplete", + message: "The selected provider could not prove an exact lookup result." }; + return cliError(5, parsed.json, envelope); + } + + const envelope = await collectAgentVisibility({ ...options, limit: parsed.limit, now }); + if (!envelope.sources.some(({ status }) => status === "ok" || status === "partial")) { + envelope.error = { + code: "all-sources-unavailable", + message: "No side-effect-free authoritative agent source is configured." + }; + return parsed.json + ? cliError(3, true, envelope) + : { + exitCode: 3, + stdout: formatAgentsTable(envelope), + stderr: `error: ${envelope.error.message}` + }; } - const limitedEnvelope: AgentsEnvelope = { - ...envelope, - agents: envelope.agents.slice(0, parsed.limit) - }; return { exitCode: 0, - stdout: parsed.json ? JSON.stringify(limitedEnvelope, null, 2) : formatAgentsTable(limitedEnvelope), + stdout: parsed.json ? JSON.stringify(envelope, null, 2) : formatAgentsTable(envelope), stderr: "" }; } export function formatAgentsTable(envelope: AgentsEnvelope): string { - const rows = envelope.agents.map((agent) => [ - `${agent.active ? "active" : "inactive"}:${agent.status}`, - agent.id, - agent.worktree ?? "—", - formatTaskGoal(agent), - agent.last_tool_call.name ?? "—", - agent.freshness_at ?? "—" - ]); - const widths = [24, 36, 36, 30, 22, 24]; - const headers = ["STATUS", "PROVIDER/RUN", "WORKTREE", "TASK/GOAL", "LAST TOOL", "FRESHNESS"]; + const widths = [23, 46, 12, 28, 20, 24]; + const headers = [ + "STATUS", + "PROVIDER/RUN", + "WORKTREE", + "TASK/GOAL", + "LAST TOOL", + "FRESHNESS" + ]; + const rows = envelope.agents.map((agent) => { + const status = safeOutputStatus(agent.status); + const active = agent.active && ACTIVE_STATUSES.has(status); + return [ + `${active ? "active" : "inactive"}:${status}`, + safeOutputAgentId(agent.id), + "—", + formatTaskGoal(agent), + "—", + safeOutputTimestamp(agent.freshness_at) + ]; + }); const lines = [ - headers.map((value, index) => padCell(value, widths[index] ?? value.length)).join(" "), - rows - .map((row) => row.map((value, index) => padCell(value, widths[index] ?? value.length)).join(" ")) - .join("\n") - ].filter(Boolean); + headers.map((value, index) => fitCell(value, widths[index] ?? 20)).join(" "), + ...rows.map((row) => + row.map((value, index) => fitCell(value, widths[index] ?? 20)).join(" ") + ) + ]; if (envelope.agents.length === 0) { - lines.push("No agents found."); + lines.push("No safely projectable agents found."); } - const warnings = envelope.sources - .filter(({ status }) => status !== "ok") - .map((source) => `${source.provider}=${source.status}${source.error ? `:${source.error.code}` : ""}`); + const warnings = envelope.sources.flatMap((source) => { + if ( + !PROVIDERS.includes(source.provider) || + (source.status === "ok" && source.coverage.complete) + ) { + return []; + } + return [ + `${source.provider}=${safeSourceStatus(source.status)}${ + source.error ? `:${safeDiagnosticCode(source.error.code)}` : "" + }` + ]; + }); if (warnings.length > 0) { - lines.push(`WARNING partial sources: ${warnings.join(", ")}`); + lines.push(`WARNING incomplete sources: ${warnings.join(", ")}`); } return lines.join("\n"); @@ -263,518 +350,689 @@ export function formatAgentsTable(envelope: AgentsEnvelope): string { export function dedupeAgents(agents: AgentRecord[]): AgentRecord[] { const deduped = new Map(); - for (const agent of agents) { - const current = deduped.get(agent.id); - if (!current || compareFreshness(agent, current) < 0) { - deduped.set(agent.id, agent); + for (const candidate of agents) { + const current = deduped.get(candidate.id); + if (!current || compareObservations(candidate, current) < 0) { + deduped.set(candidate.id, candidate); } } return [...deduped.values()]; } export function sortAgents(agents: AgentRecord[]): AgentRecord[] { - return [...agents].sort(compareFreshness); + return [...agents].sort((left, right) => { + if (left.active !== right.active) { + return left.active ? -1 : 1; + } + const observationOrder = compareObservationTime(left, right); + if (observationOrder !== 0) { + return observationOrder; + } + return left.id.localeCompare(right.id); + }); } -function compareFreshness(left: AgentRecord, right: AgentRecord): number { - if (left.active !== right.active) { - return left.active ? -1 : 1; - } - const leftTime = Date.parse(left.updated_at ?? ""); - const rightTime = Date.parse(right.updated_at ?? ""); - const normalizedLeft = Number.isFinite(leftTime) ? leftTime : Number.NEGATIVE_INFINITY; - const normalizedRight = Number.isFinite(rightTime) ? rightTime : Number.NEGATIVE_INFINITY; - if (normalizedLeft !== normalizedRight) { - return normalizedRight - normalizedLeft; - } - return left.id.localeCompare(right.id); +export async function runBoundedProviderProcess( + request: ProviderProcessRequest +): Promise { + const timeoutMs = positiveBound(request.timeoutMs, DEFAULT_PROCESS_TIMEOUT_MS); + const maxStdoutBytes = positiveBound( + request.maxStdoutBytes, + DEFAULT_PROCESS_STDOUT_BYTES + ); + const maxStderrBytes = positiveBound( + request.maxStderrBytes, + DEFAULT_PROCESS_STDERR_BYTES + ); + + return await new Promise((resolve) => { + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let settled = false; + const child = spawn(request.command, request.args, { + cwd: request.cwd, + detached: process.platform !== "win32", + env: request.env ?? process.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"] + }); + + const finish = ( + exitCode: number | null, + failure?: ProviderProcessResult["failure"] + ): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + stdout: stdout.toString("utf8"), + stderr: stderr.toString("utf8"), + exitCode, + ...(failure ? { failure } : {}) + }); + }; + + const terminateGroup = (failure: "output-limit" | "timeout"): void => { + if (settled) { + return; + } + if (child.pid && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + // The process group may already be gone. + } + } + try { + child.kill("SIGKILL"); + } catch { + // The direct child may already be gone. + } + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + finish(null, failure); + }; + + const timer = setTimeout(() => terminateGroup("timeout"), timeoutMs); + + child.stdout.on("data", (chunk: Buffer) => { + if (stdout.length + chunk.length > maxStdoutBytes) { + terminateGroup("output-limit"); + return; + } + stdout = Buffer.concat([stdout, chunk]); + }); + child.stderr.on("data", (chunk: Buffer) => { + if (stderr.length + chunk.length > maxStderrBytes) { + terminateGroup("output-limit"); + return; + } + stderr = Buffer.concat([stderr, chunk]); + }); + child.on("error", (error: NodeJS.ErrnoException) => { + finish(null, error.code === "ENOENT" ? "not-found" : "spawn-error"); + }); + child.on("close", (exitCode) => finish(exitCode)); + }); } async function collectProvider( - command: ProviderCommand, - runner: ProviderCommandRunner, - generatedAt: string + provider: AgentProvider, + exactRunId: string | null, + options: CollectAgentsOptions, + now: () => Date ): Promise { - let result: ProviderCommandResult; - try { - result = await runner(command); - } catch (error) { - return failedCollection(command.provider, "provider-execution-error", error, "error"); + if (provider === "codewith" || provider === "claude") { + return unsupportedCollection( + provider, + "side-effect-free-surface-unavailable", + `No side-effect-free structured ${provider} agent read surface is configured.` + ); + } + return await collectTodos(exactRunId, options, now); +} + +async function collectTodos( + exactRunId: string | null, + options: CollectAgentsOptions, + now: () => Date +): Promise { + if (!exactRunId) { + return unsupportedCollection( + "todos", + "side-effect-free-source-limit-unavailable", + "Todos has no side-effect-free source-level bounded list surface." + ); + } + const env = options.env ?? process.env; + const config = resolveTodosConfig(env); + if (!config) { + return unsupportedCollection( + "todos", + "side-effect-free-surface-unavailable", + "No side-effect-free Todos API is configured." + ); } + const url = buildTodosUrl(config.baseUrl, exactRunId); + const headers: Record = { Accept: "application/json" }; + if (config.apiKey) { + headers["x-api-key"] = config.apiKey; + } + const result = await (options.httpRunner ?? runBoundedHttpRequest)({ + provider: "todos", + method: "GET", + url, + headers, + timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, + maxBytes: DEFAULT_HTTP_BYTES + }); + const observedAt = safeNow(now); + if (result.failure) { - const errorByFailure: Record< - NonNullable, - { code: string; message: string; status: AgentSourceStatus } + const failureMessages: Record< + NonNullable, + AgentSourceError > = { - "not-found": { - code: "provider-command-unavailable", - message: `${command.provider} command is not installed or discoverable.`, - status: "unavailable" - }, - timeout: { - code: "provider-timeout", - message: `${command.provider} did not respond within the bounded timeout.`, - status: "error" + "network-error": { + code: "provider-network-error", + message: "The side-effect-free Todos API request failed." }, "output-limit": { code: "provider-output-limit", - message: `${command.provider} exceeded the bounded output limit.`, - status: "error" + message: "The side-effect-free Todos API exceeded the response byte limit." }, - "spawn-error": { - code: "provider-spawn-error", - message: `Unable to execute the ${command.provider} read-only surface.`, - status: "error" + timeout: { + code: "provider-timeout", + message: "The side-effect-free Todos API exceeded the wall-clock limit." } }; - const failure = errorByFailure[result.failure]; - return failedCollection(command.provider, failure.code, failure.message, failure.status); + return failedCollection("todos", observedAt, failureMessages[result.failure]); } - if (result.exitCode !== 0) { - const message = sanitizeDiagnostic(result.stderr) || `${command.provider} returned a nonzero exit.`; - return failedCollection(command.provider, "provider-nonzero-exit", message, "error"); + if (exactRunId && result.status === 404) { + return { + source: { + provider: "todos", + status: "ok", + freshness_at: observedAt, + coverage: { + complete: true, + provider_records: 0, + projected_records: 0, + dropped_records: 0 + } + }, + agents: [] + }; } - let payload: unknown; - try { - payload = JSON.parse(result.stdout) as unknown; - } catch { - return failedCollection( - command.provider, - "provider-invalid-json", - `${command.provider} returned invalid JSON.`, - "error" - ); + if (result.status === null || result.status < 200 || result.status >= 300) { + return failedCollection("todos", observedAt, { + code: "provider-http-error", + message: "The side-effect-free Todos API returned a non-success status." + }); } - let normalized: NormalizeResult; + let payload: unknown; try { - normalized = normalizeProvider(command.provider, payload, generatedAt); + payload = JSON.parse(result.body) as unknown; } catch { - return failedCollection( - command.provider, - "provider-invalid-payload", - `${command.provider} returned an unsupported JSON shape.`, - "error" - ); + return failedCollection("todos", observedAt, { + code: "provider-invalid-json", + message: "The side-effect-free Todos API returned invalid JSON." + }); } - if (normalized.skipped > 0) { + return normalizeTodosExact(payload, exactRunId, observedAt); +} + +function normalizeTodosExact( + payload: unknown, + exactRunId: string, + observedAt: string +): ProviderCollection { + if (!isRecord(payload) || !isRecord(payload.task)) { + return failedCollection("todos", observedAt, { + code: "provider-invalid-payload", + message: "The side-effect-free Todos API returned an unsupported exact record." + }); + } + const raw = payload.task; + if (safeUuid(raw.id) !== exactRunId) { + return failedCollection("todos", observedAt, { + code: "provider-identity-mismatch", + message: "The side-effect-free Todos API returned a different exact record." + }); + } + if (!hasTodosAgentProvenance(raw, observedAt)) { return { source: { - provider: command.provider, + provider: "todos", status: "partial", - freshness_at: generatedAt, + freshness_at: observedAt, + coverage: { + complete: true, + provider_records: 1, + projected_records: 0, + dropped_records: 1 + }, error: { - code: "provider-records-skipped", - message: `${normalized.skipped} malformed provider record(s) were skipped.` + code: "agent-provenance-missing", + message: "The exact Todos task has no authoritative agent, session, or live lease provenance." } }, - agents: normalized.agents + agents: [] }; } - + const record = normalizeTodosTask(raw, observedAt); + if (!record) { + return { + source: { + provider: "todos", + status: "partial", + freshness_at: observedAt, + coverage: { + complete: false, + provider_records: 1, + projected_records: 0, + dropped_records: 1 + }, + error: { + code: "record-withheld", + message: "The exact Todos record did not satisfy the safe-output contract." + } + }, + agents: [] + }; + } + const incomplete = record.gaps.length > 0; return { source: { - provider: command.provider, - status: "ok", - freshness_at: generatedAt + provider: "todos", + status: incomplete ? "partial" : "ok", + freshness_at: observedAt, + coverage: { + complete: true, + provider_records: 1, + projected_records: 1, + dropped_records: 0 + }, + ...(incomplete + ? { + error: { + code: "normalized-fields-unavailable", + message: "The exact Todos record has explicit unavailable normalized fields." + } + } + : {}) }, - agents: normalized.agents + agents: [record] }; } -function normalizeProvider( - provider: AgentProvider, - payload: unknown, - sourceFreshness: string -): NormalizeResult { - switch (provider) { - case "codewith": - return normalizeCodewith(payload, sourceFreshness); - case "claude": - return normalizeClaude(payload, sourceFreshness); - case "todos": - return normalizeTodos(payload, sourceFreshness); - } -} - -function normalizeCodewith(payload: unknown, sourceFreshness: string): NormalizeResult { - if (!isRecord(payload) || !Array.isArray(payload.data)) { - throw new Error("unsupported Codewith payload"); +function normalizeTodosTask( + raw: Record, + observedAt: string +): AgentRecord | null { + const runId = safeUuid(raw.id); + if (!runId) { + return null; } - const agents: AgentRecord[] = []; - let skipped = 0; - - for (const raw of payload.data.slice(0, MAX_AGENTS_LIMIT)) { - if (!isRecord(raw)) { - skipped += 1; - continue; - } - const runId = safeOpaqueId(raw.agentId); - if (!runId) { - skipped += 1; - continue; - } - const status = boundedText(raw.status, 64) ?? "unknown"; - const updatedAt = latestTimestamp(raw.updatedAt, raw.heartbeatAt); - const profileAlias = safeProfileAlias(raw.authProfileRef); - const gaps = [ - "worktree unavailable from Codewith list surface", - "branch unavailable from Codewith list surface", - "last_tool_call unavailable from Codewith list surface", - "goal unavailable from Codewith list surface", - "task unavailable from Codewith list surface" - ]; - if (status === "unknown") { - gaps.push("status unavailable from Codewith list surface"); - } - if (!updatedAt) { - gaps.push("updated_at unavailable from Codewith list surface"); - } - if (!profileAlias) { - gaps.push( - raw.authProfileRef == null - ? "profile unavailable from Codewith list surface" - : "profile reference withheld because it is not a safe alias" - ); - } - - agents.push({ - id: `codewith:${runId}`, - provider: "codewith", - run_id: runId, - status, - active: isActiveStatus(status), - started_at: normalizeTimestamp(raw.startedAt ?? raw.createdAt), - updated_at: updatedAt, - worktree: null, - branch: null, - last_tool_call: emptyLastToolCall(), - goal: emptyGoal(), - task: emptyTask(), - profile: { alias: profileAlias }, - freshness_at: updatedAt ?? sourceFreshness, - gaps - }); + const observedAtMs = Date.parse(observedAt); + const status = normalizeStatus(raw.status); + let startedAt = normalizeTimestamp(raw.started_at ?? raw.created_at, observedAtMs); + const updatedAt = normalizeTimestamp(raw.updated_at ?? raw.synced_at, observedAtMs); + if (startedAt && updatedAt && Date.parse(startedAt) > Date.parse(updatedAt)) { + startedAt = null; } + const metadata = isRecord(raw.metadata) ? raw.metadata : {}; + const shortId = safeShortId(raw.short_id); + const profileAlias = safeProfileAlias(raw.profile_alias ?? metadata.profile_alias); + const gaps: string[] = []; + + if (!status.complete) gaps.push("status"); + if (!startedAt) gaps.push("started_at"); + if (!updatedAt) gaps.push("updated_at"); + gaps.push( + "worktree", + "branch", + "last_tool_call.name", + "last_tool_call.at", + "last_tool_call.summary", + "goal.id", + "goal.title", + "goal.status" + ); + if (!shortId) gaps.push("task.short_id"); + gaps.push("task.title"); + if (!status.complete) gaps.push("task.status"); + if (!profileAlias) gaps.push("profile.alias"); + if (!updatedAt) gaps.push("freshness_at"); - return { agents, skipped }; + return { + id: `todos:${runId}`, + provider: "todos", + run_id: runId, + status: status.value, + active: status.active, + started_at: startedAt, + updated_at: updatedAt, + worktree: null, + branch: null, + last_tool_call: { name: null, at: null, summary: null }, + goal: { id: null, title: null, status: null }, + task: { + id: runId, + short_id: shortId, + title: null, + status: status.complete ? status.value : null + }, + profile: { alias: profileAlias }, + freshness_at: updatedAt, + gaps: [...new Set(gaps)].sort() + }; } -function normalizeClaude(payload: unknown, sourceFreshness: string): NormalizeResult { - if (!Array.isArray(payload)) { - throw new Error("unsupported Claude payload"); +function hasTodosAgentProvenance( + raw: Record, + observedAt: string +): boolean { + const metadata = isRecord(raw.metadata) ? raw.metadata : {}; + const directValues = [ + raw.agent_id, + raw.session_id, + raw.runner_id, + metadata.agent_id, + metadata.session_id, + metadata.run_id + ]; + if (directValues.some(hasOpaqueProvenanceValue)) { + return true; } - const agents: AgentRecord[] = []; - let skipped = 0; - for (const raw of payload.slice(0, MAX_AGENTS_LIMIT)) { - if (!isRecord(raw)) { - skipped += 1; - continue; - } - const runId = safeOpaqueId(raw.sessionId ?? raw.id); - if (!runId) { - skipped += 1; - continue; - } - const status = boundedText(raw.status ?? raw.state, 64) ?? "unknown"; - const worktree = safeAbsolutePath(raw.cwd); - const gaps = [ - "updated_at unavailable from Claude agents surface", - "branch unavailable from Claude agents surface", - "last_tool_call unavailable from Claude agents surface", - "goal unavailable from Claude agents surface", - "task unavailable from Claude agents surface", - "profile unavailable from Claude agents surface", - "agent freshness unavailable; using source observation time" - ]; - if (status === "unknown") { - gaps.push("status unavailable from Claude agents surface"); - } - if (!worktree) { - gaps.push("worktree unavailable from Claude agents surface"); - } - - agents.push({ - id: `claude:${runId}`, - provider: "claude", - run_id: runId, - status, - active: !isTerminalStatus(status), - started_at: normalizeTimestamp(raw.startedAt), - updated_at: null, - worktree, - branch: null, - last_tool_call: emptyLastToolCall(), - goal: emptyGoal(), - task: emptyTask(), - profile: { alias: null }, - freshness_at: sourceFreshness, - gaps - }); + const leaseOwner = raw.locked_by ?? metadata.locked_by; + const leaseExpiry = raw.lock_expires_at ?? metadata.lock_expires_at; + if (!hasOpaqueProvenanceValue(leaseOwner)) { + return false; } + const expiryMs = parseTimestampMs(leaseExpiry); + const observedMs = Date.parse(observedAt); + return ( + expiryMs !== null && + expiryMs >= observedMs && + expiryMs <= observedMs + 24 * 60 * 60 * 1000 + ); +} - return { agents, skipped }; +function hasOpaqueProvenanceValue(value: unknown): boolean { + return ( + typeof value === "string" && + value.length >= 1 && + value.length <= 256 && + value.trim() === value && + !NONPRINTING_PATTERN.test(value) + ); } -function normalizeTodos(payload: unknown, sourceFreshness: string): NormalizeResult { - if (!Array.isArray(payload)) { - throw new Error("unsupported Todos payload"); +function normalizeStatus(value: unknown): NormalizedStatus { + if (typeof value !== "string") { + return { value: "unknown", active: false, complete: false }; } - const agents: AgentRecord[] = []; - let skipped = 0; - - for (const raw of payload.slice(0, MAX_AGENTS_LIMIT)) { - if (!isRecord(raw)) { - skipped += 1; - continue; - } - const runId = safeOpaqueId(raw.id); - if (!runId) { - skipped += 1; - continue; - } - const status = boundedText(raw.status, 64) ?? "unknown"; - const metadata = isRecord(raw.metadata) ? raw.metadata : {}; - const worktree = safeAbsolutePath(raw.working_dir ?? metadata.worktree); - const branch = safeBranch(metadata.branch); - const updatedAt = normalizeTimestamp(raw.updated_at ?? raw.synced_at); - const gaps = [ - "last_tool_call unavailable from Todos active surface", - "goal unavailable from Todos active surface", - "profile unavailable from Todos active surface" - ]; - if (status === "unknown") { - gaps.push("status unavailable from Todos active surface"); - } - if (!worktree) { - gaps.push("worktree unavailable from Todos active surface"); - } - if (!branch) { - gaps.push("branch unavailable from Todos active surface"); - } - if (!updatedAt) { - gaps.push("updated_at unavailable from Todos active surface"); - } - - agents.push({ - id: `todos:${runId}`, - provider: "todos", - run_id: runId, - status, - active: !isTerminalStatus(status), - started_at: normalizeTimestamp(raw.started_at ?? raw.created_at), - updated_at: updatedAt, - worktree, - branch, - last_tool_call: emptyLastToolCall(), - goal: emptyGoal(), - task: { - id: runId, - short_id: boundedText(raw.short_id, 40), - title: boundedText(raw.title, 120), - status - }, - profile: { alias: null }, - freshness_at: updatedAt ?? sourceFreshness, - gaps - }); + const candidate = value.trim().toLowerCase(); + if (candidate === "unknown" || !(candidate in STATUS_PRECEDENCE)) { + return { value: "unknown", active: false, complete: false }; } - - return { agents, skipped }; -} - -function failedCollection( - provider: AgentProvider, - code: string, - message: unknown, - status: AgentSourceStatus -): ProviderCollection { return { - source: { - provider, - status, - freshness_at: null, - error: makeDiagnostic(code, message) - }, - agents: [] - }; -} - -function errorEnvelope(generatedAt: string, error: AgentSourceError): AgentsEnvelope { - return { - schema_version: AGENTS_SCHEMA_VERSION, - generated_at: generatedAt, - partial: true, - sources: [], - agents: [], - error + value: candidate, + active: ACTIVE_STATUSES.has(candidate), + complete: true }; } -function makeDiagnostic(code: string, value: unknown): AgentSourceError { - const raw = value instanceof Error ? value.message : String(value); - return { - code, - message: sanitizeDiagnostic(raw) || "Provider visibility failed without a safe diagnostic." - }; +function normalizeTimestamp(value: unknown, observedAtMs: number): string | null { + const milliseconds = parseTimestampMs(value); + if ( + milliseconds === null || + milliseconds < EARLIEST_VALID_TIME_MS || + milliseconds > observedAtMs + ) { + return null; + } + return new Date(milliseconds).toISOString(); } -function sanitizeDiagnostic(value: unknown): string { - if (typeof value !== "string") { - return ""; - } - return bound( - redactVisibilitySecrets(value) - .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]") - .replace(/\/(?:home|Users)\/[^/\s]+/g, "~") - .replace(/[\u0000-\u001f\u007f]+/g, " ") - .replace(/\s+/g, " ") - .trim(), - 200 - ); +function parseTimestampMs(value: unknown): number | null { + let milliseconds: number; + if (typeof value === "number" && Number.isFinite(value)) { + milliseconds = value > 10_000_000_000 ? value : value * 1000; + } else if (typeof value === "string" && value.trim() !== "") { + const numeric = Number(value); + milliseconds = Number.isFinite(numeric) + ? numeric > 10_000_000_000 + ? numeric + : numeric * 1000 + : Date.parse(value); + } else { + return null; + } + return Number.isFinite(milliseconds) ? milliseconds : null; } -function boundedText(value: unknown, maximum: number): string | null { +function safeUuid(value: unknown): string | null { if (typeof value !== "string") { return null; } - const sanitized = redactVisibilitySecrets(value) - .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[REDACTED_EMAIL]") - .replace(/[\u0000-\u001f\u007f]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - return sanitized ? bound(sanitized, maximum) : null; + const candidate = value.toLowerCase(); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + candidate + ) + ? candidate + : null; } -function safeOpaqueId(value: unknown): string | null { +function safeShortId(value: unknown): string | null { if (typeof value !== "string") { return null; } - const trimmed = value.trim(); - return /^[A-Za-z0-9._-]{1,256}$/.test(trimmed) && redactVisibilitySecrets(trimmed) === trimmed - ? trimmed - : null; + return /^[A-Z][A-Z0-9]{0,7}-[0-9]{1,8}$/.test(value) ? value : null; } function safeProfileAlias(value: unknown): string | null { if (typeof value !== "string") { return null; } - const trimmed = value.trim(); - if ( - !/^[A-Za-z][A-Za-z0-9._-]{0,31}$/.test(trimmed) || - /^(?:sk|gh[pousr]|token|secret|key|auth|cred)[._-]/i.test(trimmed) || - /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(trimmed) || - redactVisibilitySecrets(trimmed) !== trimmed - ) { - return null; - } - return trimmed; + return /^account[0-9]{3}$/.test(value) ? value : null; } -function redactVisibilitySecrets(value: string): string { - return redactSensitiveText(value) - .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[REDACTED_GITHUB_TOKEN]") - .replace(/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, "[REDACTED_ANTHROPIC_KEY]") - .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[REDACTED_JWT]") - .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]") - .replace(/(https?:\/\/)[^/\s:@]+:[^@\s]+@/gi, "$1[REDACTED]@"); -} - -function safeAbsolutePath(value: unknown): string | null { - if (typeof value !== "string" || !isAbsolute(value) || value.length > 512) { +function resolveTodosConfig( + env: Readonly> +): TodosConfig | null { + const rawBaseUrl = env.TODOS_URL ?? env.TODOS_API_URL; + if (!rawBaseUrl) { + return null; + } + let baseUrl: URL; + try { + baseUrl = new URL(rawBaseUrl); + } catch { return null; } if ( - /(?:^|\/)\.(?:ssh|aws|config|codewith|claude)(?:\/|$)/i.test(value) || - /(?:^|\/)(?:secrets?|credentials?)(?:\/|$)/i.test(value) || - /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i.test(value) || - redactVisibilitySecrets(value) !== value || - /[\u0000-\u001f\u007f]/.test(value) + (baseUrl.protocol !== "https:" && + !(baseUrl.protocol === "http:" && isLoopbackHostname(baseUrl.hostname))) || + baseUrl.username !== "" || + baseUrl.password !== "" || + baseUrl.search !== "" || + baseUrl.hash !== "" ) { return null; } - return value; + return { + baseUrl, + apiKey: env.TODOS_API_KEY || null + }; } -function safeBranch(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return /^[A-Za-z0-9._/-]{1,200}$/.test(trimmed) && redactVisibilitySecrets(trimmed) === trimmed - ? trimmed - : null; +function isLoopbackHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } -function normalizeTimestamp(value: unknown): string | null { - let milliseconds: number; - if (typeof value === "number" && Number.isFinite(value)) { - milliseconds = value > 10_000_000_000 ? value : value * 1000; - } else if (typeof value === "string" && value.trim()) { - const numeric = Number(value); - milliseconds = Number.isFinite(numeric) - ? numeric > 10_000_000_000 - ? numeric - : numeric * 1000 - : Date.parse(value); - } else { - return null; - } - if (!Number.isFinite(milliseconds)) { - return null; - } - const date = new Date(milliseconds); - return Number.isNaN(date.getTime()) ? null : date.toISOString(); +function buildTodosUrl(baseUrl: URL, exactRunId: string): string { + const url = new URL(baseUrl.toString()); + const prefix = url.pathname.replace(/\/+$/, ""); + url.pathname = `${prefix}/v1/tasks/${encodeURIComponent(exactRunId)}`; + url.search = ""; + url.hash = ""; + return url.toString(); } -function latestTimestamp(...values: unknown[]): string | null { - const timestamps = values - .map(normalizeTimestamp) - .filter((value): value is string => value !== null) - .sort((left, right) => Date.parse(right) - Date.parse(left)); - return timestamps[0] ?? null; -} +export async function runBoundedHttpRequest( + request: ProviderHttpRequest +): Promise { + const controller = new AbortController(); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, request.timeoutMs); -function isTerminalStatus(status: string): boolean { - return /^(?:cancelled|completed|done|failed|stopped|terminated|archived|succeeded)$/i.test(status); + try { + const response = await fetch(request.url, { + method: request.method, + headers: request.headers, + signal: controller.signal, + redirect: "error" + }); + if (!response.body) { + return { status: response.status, body: "" }; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + const item = await reader.read(); + if (item.done) { + break; + } + bytes += item.value.byteLength; + if (bytes > request.maxBytes) { + controller.abort(); + await reader.cancel().catch(() => undefined); + return { status: response.status, body: "", failure: "output-limit" }; + } + chunks.push(item.value); + } + return { + status: response.status, + body: Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8") + }; + } catch { + return { + status: null, + body: "", + failure: timedOut ? "timeout" : "network-error" + }; + } finally { + clearTimeout(timer); + } } -function isActiveStatus(status: string): boolean { - return !isTerminalStatus(status); +function unsupportedCollection( + provider: AgentProvider, + code: string, + message: string +): ProviderCollection { + return { + source: { + provider, + status: "unavailable", + freshness_at: null, + coverage: { + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }, + error: { code, message } + }, + agents: [] + }; } -function emptyLastToolCall(): AgentRecord["last_tool_call"] { - return { name: null, at: null, summary: null }; +function failedCollection( + provider: AgentProvider, + observedAt: string, + error: AgentSourceError +): ProviderCollection { + return { + source: { + provider, + status: "error", + freshness_at: observedAt, + coverage: { + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }, + error + }, + agents: [] + }; } -function emptyGoal(): AgentRecord["goal"] { - return { id: null, title: null, status: null }; +function applyResultLimitCoverage( + sources: AgentSource[], + allAgents: AgentRecord[], + visibleAgents: AgentRecord[] +): void { + if (visibleAgents.length === allAgents.length) { + return; + } + const visibleIds = new Set(visibleAgents.map(({ id }) => id)); + for (const source of sources) { + const sourceAgents = allAgents.filter(({ provider }) => provider === source.provider); + const visibleSourceAgents = sourceAgents.filter(({ id }) => visibleIds.has(id)); + const omitted = sourceAgents.length - visibleSourceAgents.length; + if (omitted <= 0) { + continue; + } + source.status = "partial"; + source.coverage = { + ...source.coverage, + complete: false, + projected_records: visibleSourceAgents.length, + dropped_records: + source.coverage.dropped_records === null + ? null + : source.coverage.dropped_records + omitted + }; + source.error ??= { + code: "result-limit-applied", + message: "The requested result limit omitted safely projectable records." + }; + } } -function emptyTask(): AgentRecord["task"] { - return { id: null, short_id: null, title: null, status: null }; +function compareObservations(left: AgentRecord, right: AgentRecord): number { + const timeOrder = compareObservationTime(left, right); + if (timeOrder !== 0) { + return timeOrder; + } + const statusOrder = + (STATUS_PRECEDENCE[right.status] ?? 0) - (STATUS_PRECEDENCE[left.status] ?? 0); + if (statusOrder !== 0) { + return statusOrder; + } + return canonicalRecord(left).localeCompare(canonicalRecord(right)); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function compareObservationTime(left: AgentRecord, right: AgentRecord): number { + return observationTime(right) - observationTime(left); } -function bound(value: string, maximum: number): string { - if (value.length <= maximum) { - return value; +function observationTime(agent: AgentRecord): number { + for (const value of [agent.updated_at, agent.freshness_at, agent.started_at]) { + const parsed = value ? Date.parse(value) : Number.NaN; + if (Number.isFinite(parsed)) { + return parsed; + } } - return `${value.slice(0, Math.max(0, maximum - 1))}…`; + return Number.NEGATIVE_INFINITY; +} + +function canonicalRecord(agent: AgentRecord): string { + return JSON.stringify(agent); } function parseAgentsArguments(args: string[]): ParsedAgentsArguments { let json = false; let limit = DEFAULT_AGENTS_LIMIT; + let limitSpecified = false; const positionals: string[] = []; for (let index = 0; index < args.length; index += 1) { @@ -785,146 +1043,235 @@ function parseAgentsArguments(args: string[]): ParsedAgentsArguments { } if (argument === "--limit") { const value = args[index + 1]; - if (!value) { - throw new Error("--limit requires an integer."); - } - limit = parseLimit(value); + if (!value) throw new Error("invalid limit"); + limit = validateLimit(Number(value)); + limitSpecified = true; index += 1; continue; } if (argument.startsWith("--limit=")) { - limit = parseLimit(argument.slice("--limit=".length)); + limit = validateLimit(Number(argument.slice("--limit=".length))); + limitSpecified = true; continue; } if (argument.startsWith("-")) { - throw new Error(`Unknown option: ${argument}`); + throw new Error("unknown option"); } positionals.push(argument); } if (positionals.length === 0 || (positionals.length === 1 && positionals[0] === "list")) { - return { json, limit, mode: "list", id: null }; + return { json, limit, limitSpecified, mode: "list", id: null }; } - if (positionals[0] === "show" && positionals.length === 2) { - return { json, limit, mode: "show", id: positionals[1] ?? null }; + if (positionals.length === 2 && positionals[0] === "show") { + return { + json, + limit, + limitSpecified, + mode: "show", + id: positionals[1] ?? null + }; } - throw new Error("Usage: tai agents [--json] [--limit <1-200>] | tai agents show : [--json]"); + throw new Error("invalid arguments"); } -function parseLimit(value: string): number { - if (!/^\d+$/.test(value)) { - throw new Error("--limit must be an integer between 1 and 200."); - } - const parsed = Number(value); - if (parsed < 1 || parsed > MAX_AGENTS_LIMIT) { - throw new Error("--limit must be between 1 and 200."); +function validateLimit(value: number): number { + if (!Number.isSafeInteger(value) || value < 1 || value > MAX_AGENTS_LIMIT) { + throw new Error("invalid limit"); } - return parsed; + return value; } -function isValidAgentId(value: string | null): value is string { +function parseAgentId( + value: string | null +): { provider: AgentProvider; runId: string } | null { if (!value) { - return false; + return null; } const separator = value.indexOf(":"); - if (separator <= 0 || separator === value.length - 1) { - return false; + if (separator <= 0 || separator !== value.lastIndexOf(":")) { + return null; } const provider = value.slice(0, separator); - const runId = value.slice(separator + 1); - return ( - (provider === "codewith" || provider === "claude" || provider === "todos") && - safeOpaqueId(runId) === runId - ); + const runId = safeUuid(value.slice(separator + 1)); + return PROVIDERS.includes(provider as AgentProvider) && runId + ? { provider: provider as AgentProvider, runId } + : null; +} + +function errorEnvelope(generatedAt: string, error: AgentSourceError): AgentsEnvelope { + return { + schema_version: AGENTS_SCHEMA_VERSION, + generated_at: generatedAt, + partial: true, + sources: [], + agents: [], + error + }; +} + +function cliError( + exitCode: number, + json: boolean, + envelope: AgentsEnvelope +): AgentsCliResult { + return { + exitCode, + stdout: json ? JSON.stringify(envelope, null, 2) : "", + stderr: json ? "" : `error: ${envelope.error?.message ?? "Agent visibility failed."}` + }; } function formatTaskGoal(agent: AgentRecord): string { - if (agent.task.id) { - return bound(`${agent.task.short_id ?? agent.task.id} ${agent.task.title ?? ""}`.trim(), 30); + const shortId = safeShortId(agent.task.short_id); + if (shortId) { + return shortId; } - if (agent.goal.id) { - return bound(`${agent.goal.id} ${agent.goal.title ?? ""}`.trim(), 30); + const taskId = safeUuid(agent.task.id); + if (taskId) { + return taskId; + } + const goalId = safeUuid(agent.goal.id); + if (goalId) { + return goalId; } return "—"; } function formatAgentDetails(agent: AgentRecord, sources: AgentSource[]): string { - const sourceLines = sources.map( - (source) => - ` ${source.provider}: ${source.status}${source.error ? ` (${source.error.code})` : ""}` - ); + const safeStatus = safeOutputStatus(agent.status); + const safeGaps = agent.gaps.filter((gap) => SAFE_GAP_NAMES.has(gap)).sort(); return [ - `Agent: ${agent.id}`, - `Status: ${agent.status} (${agent.active ? "active" : "inactive"})`, - `Started: ${agent.started_at ?? "—"}`, - `Updated: ${agent.updated_at ?? "—"}`, - `Worktree: ${agent.worktree ?? "—"}`, - `Branch: ${agent.branch ?? "—"}`, - `Task: ${agent.task.id ? `${agent.task.short_id ?? agent.task.id} ${agent.task.title ?? ""}`.trim() : "—"}`, - `Goal: ${agent.goal.id ? `${agent.goal.id} ${agent.goal.title ?? ""}`.trim() : "—"}`, - `Last tool: ${agent.last_tool_call.name ?? "—"}`, - `Profile: ${agent.profile.alias ?? "—"}`, - `Freshness: ${agent.freshness_at ?? "—"}`, - `Gaps: ${agent.gaps.length > 0 ? agent.gaps.join("; ") : "none"}`, + `Agent: ${safeOutputAgentId(agent.id)}`, + `Status: ${safeStatus} (${agent.active && ACTIVE_STATUSES.has(safeStatus) ? "active" : "inactive"})`, + `Started: ${safeOutputTimestamp(agent.started_at)}`, + `Updated: ${safeOutputTimestamp(agent.updated_at)}`, + "Worktree: —", + "Branch: —", + `Task: ${formatTaskGoal(agent)}`, + `Goal: ${safeUuid(agent.goal.id) ?? "—"}`, + "Last tool: —", + `Profile: ${safeProfileAlias(agent.profile.alias) ?? "—"}`, + `Freshness: ${safeOutputTimestamp(agent.freshness_at)}`, + `Gaps: ${safeGaps.length > 0 ? safeGaps.join(", ") : "none"}`, "Sources:", - ...sourceLines + ...sources.flatMap((source) => + PROVIDERS.includes(source.provider) + ? [ + ` ${source.provider}: ${safeSourceStatus(source.status)}${ + source.error ? ` (${safeDiagnosticCode(source.error.code)})` : "" + }` + ] + : [] + ) ].join("\n"); } -function padCell(value: string, width: number): string { - return bound(value, width).padEnd(width, " "); +function safeOutputAgentId(value: unknown): string { + if (typeof value !== "string") { + return "—"; + } + const parsed = parseAgentId(value); + return parsed && `${parsed.provider}:${parsed.runId}` === value ? value : "—"; } -async function runProviderCommand(command: ProviderCommand): Promise { - return await new Promise((resolve) => { - let stdout = Buffer.alloc(0); - let stderr = Buffer.alloc(0); - let failure: ProviderCommandResult["failure"]; - let resolved = false; - const child = spawn(command.command, command.args, { - shell: false, - stdio: ["ignore", "pipe", "pipe"] - }); +function safeOutputStatus(value: unknown): string { + return normalizeStatus(value).value; +} - const finish = (exitCode: number | null): void => { - if (resolved) { - return; - } - resolved = true; - clearTimeout(timer); - resolve({ - stdout: stdout.toString("utf8"), - stderr: stderr.toString("utf8"), - exitCode, - ...(failure ? { failure } : {}) - }); - }; +function safeOutputTimestamp(value: unknown): string { + if (typeof value !== "string") { + return "—"; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) && + parsed >= EARLIEST_VALID_TIME_MS && + parsed <= Date.now() && + new Date(parsed).toISOString() === value + ? value + : "—"; +} - const timer = setTimeout(() => { - failure = "timeout"; - child.kill("SIGKILL"); - }, PROVIDER_TIMEOUT_MS); +function safeSourceStatus(value: unknown): AgentSourceStatus { + return value === "ok" || + value === "partial" || + value === "unavailable" || + value === "error" + ? value + : "error"; +} - child.stdout.on("data", (chunk: Buffer) => { - if (stdout.length + chunk.length > MAX_STDOUT_BYTES) { - failure = "output-limit"; - child.kill("SIGKILL"); - return; - } - stdout = Buffer.concat([stdout, chunk]); - }); +function safeDiagnosticCode(value: unknown): string { + return typeof value === "string" && SAFE_DIAGNOSTIC_CODES.has(value) + ? value + : "diagnostic-withheld"; +} - child.stderr.on("data", (chunk: Buffer) => { - const remaining = MAX_STDERR_BYTES - stderr.length; - if (remaining > 0) { - stderr = Buffer.concat([stderr, chunk.subarray(0, remaining)]); - } - }); +function fitCell(value: string, width: number): string { + const graphemes = [...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(value)].map( + ({ segment }) => segment + ); + const fitted = + graphemes.length <= width + ? value + : `${graphemes.slice(0, Math.max(0, width - 1)).join("")}…`; + const fittedLength = [ + ...new Intl.Segmenter("en", { granularity: "grapheme" }).segment(fitted) + ].length; + return fitted.padEnd(fitted.length + Math.max(0, width - fittedLength), " "); +} - child.on("error", (error: NodeJS.ErrnoException) => { - failure = error.code === "ENOENT" ? "not-found" : "spawn-error"; - finish(null); - }); - child.on("close", (exitCode) => finish(exitCode)); - }); +function safeNow(now: () => Date): string { + const value = now(); + if (!(value instanceof Date) || Number.isNaN(value.getTime())) { + return new Date(0).toISOString(); + } + return value.toISOString(); +} + +function positiveBound(value: number | undefined, fallback: number): number { + return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : fallback; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } + +const NONPRINTING_PATTERN = + /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u0080-\u009f\u200b\u202a-\u202e\u2066-\u2069]/u; + +const SAFE_GAP_NAMES = new Set([ + "branch", + "freshness_at", + "goal.id", + "goal.status", + "goal.title", + "last_tool_call.at", + "last_tool_call.name", + "last_tool_call.summary", + "profile.alias", + "started_at", + "status", + "task.short_id", + "task.status", + "task.title", + "updated_at", + "worktree" +]); + +const SAFE_DIAGNOSTIC_CODES = new Set([ + "agent-provenance-missing", + "diagnostic-withheld", + "normalized-fields-unavailable", + "provider-http-error", + "provider-identity-mismatch", + "provider-invalid-json", + "provider-invalid-payload", + "provider-network-error", + "provider-output-limit", + "provider-timeout", + "record-withheld", + "result-limit-applied", + "side-effect-free-source-limit-unavailable", + "side-effect-free-surface-unavailable" +]); diff --git a/tests/agents.test.ts b/tests/agents.test.ts index a98d561..38e9d69 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -1,225 +1,677 @@ import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { AGENTS_SCHEMA_VERSION, collectAgentVisibility, + dedupeAgents, formatAgentsTable, runAgentsCli, - type ProviderCommand, - type ProviderCommandResult, - type ProviderCommandRunner + runBoundedHttpRequest, + runBoundedProviderProcess, + type AgentRecord, + type ProviderHttpRequest, + type ProviderHttpResult, + type ProviderHttpRunner } from "../src/agents"; -const NOW = new Date("2026-07-23T12:00:00.000Z"); -const SECRET_LIKE_GITHUB_TOKEN = ["ghp", "1234567890abcdefghijklmnopqrst"].join("_"); +const REQUEST_AT = new Date("2026-07-23T12:00:00.000Z"); +const OBSERVED_AT = new Date("2026-07-23T12:00:05.000Z"); +const TODOS_ENV = { + TODOS_URL: "https://todos.example.test", + TODOS_API_KEY: "test-only-key" +}; -function result(payload: unknown): ProviderCommandResult { +function uuid(index: number): string { + return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; +} + +function task( + index: number, + overrides: Record = {} +): Record { return { - stdout: JSON.stringify(payload), - stderr: "", - exitCode: 0 + id: uuid(index), + short_id: `E-${String(index).padStart(5, "0")}`, + title: `Task ${index}`, + status: "in_progress", + started_at: "2026-07-23T10:00:00.000Z", + updated_at: "2026-07-23T11:00:00.000Z", + agent_id: uuid(index + 10_000), + ...overrides }; } -function fixtures(): Record { +function listResult( + tasks: unknown[], + total = tasks.length, + status = 200 +): ProviderHttpResult { return { - codewith: result({ - data: [ - { - agentId: "cw-old", - status: "running", - startedAt: 1_753_200_000, - updatedAt: 1_753_200_100, - authProfileRef: "account001", - statusReason: "raw transcript and sk-1234567890abcdef" - }, - { - agentId: "cw-old", - status: "running", - startedAt: 1_753_200_000, - updatedAt: 1_753_200_200, - authProfileRef: "sk-secret-profile-reference" - }, - { - agentId: "cw-complete", - status: "completed", - startedAt: 1_753_100_000, - updatedAt: 1_753_100_100 - } - ] - }), - claude: result([ - { - sessionId: "claude-new", - status: "busy", - startedAt: 1_753_300_000_000, - cwd: "/workspace/repo" - } - ]), - todos: result([ - { - id: "task-newest", - short_id: "E-00104", - title: `Visible task ${SECRET_LIKE_GITHUB_TOKEN} ${"x".repeat(180)}`, - status: "in_progress", - started_at: "2026-07-23T10:00:00.000Z", - updated_at: "2026-07-23T11:59:00.000Z", - working_dir: "/workspace/tai", - metadata: { branch: "feat/agents" }, - description: "full prompt must never be projected" - } - ]) + status, + body: JSON.stringify({ tasks, count: tasks.length, total }) + }; +} + +function exactResult(value: unknown, status = 200): ProviderHttpResult { + return { + status, + body: JSON.stringify({ task: value }) }; } -function fakeRunner( - overrides: Partial> = {} -): { runner: ProviderCommandRunner; calls: ProviderCommand[] } { - const calls: ProviderCommand[] = []; - const responses = { ...fixtures(), ...overrides }; +function fakeHttp( + resultOrHandler: + | ProviderHttpResult + | ((request: ProviderHttpRequest) => ProviderHttpResult | Promise) +): { runner: ProviderHttpRunner; calls: ProviderHttpRequest[] } { + const calls: ProviderHttpRequest[] = []; return { calls, - runner: async (command) => { - calls.push(command); - return responses[command.provider]; + runner: async (request) => { + calls.push(request); + return typeof resultOrHandler === "function" + ? await resultOrHandler(request) + : resultOrHandler; } }; } -describe("agent visibility normalization", () => { - test("normalizes, redacts, bounds, deduplicates, and sorts provider records", async () => { - const { runner } = fakeRunner(); - const envelope = await collectAgentVisibility({ runner, now: () => NOW }); +function fixedNow(): Date { + return REQUEST_AT; +} - expect(envelope.schema_version).toBe(AGENTS_SCHEMA_VERSION); - expect(envelope.generated_at).toBe(NOW.toISOString()); - expect(envelope.partial).toBe(false); - expect(envelope.sources.map(({ status }) => status)).toEqual(["ok", "ok", "ok"]); - expect(envelope.agents).toHaveLength(4); - expect(envelope.agents[0]?.id).toBe("todos:task-newest"); - expect(envelope.agents.at(-1)?.id).toBe("codewith:cw-complete"); - expect(envelope.agents.filter(({ id }) => id === "codewith:cw-old")).toHaveLength(1); - - const codewith = envelope.agents.find(({ id }) => id === "codewith:cw-old"); - expect(codewith?.updated_at).toBe("2025-07-22T16:03:20.000Z"); - expect(codewith?.profile.alias).toBeNull(); - expect(codewith?.last_tool_call).toEqual({ name: null, at: null, summary: null }); - expect(codewith?.gaps).toContain("last_tool_call unavailable from Codewith list surface"); - expect(codewith?.gaps).toContain("profile reference withheld because it is not a safe alias"); - - const todos = envelope.agents.find(({ provider }) => provider === "todos"); - expect(todos?.task.title).toContain("[REDACTED_"); - expect(todos?.task.title).not.toContain(SECRET_LIKE_GITHUB_TOKEN); - expect(todos?.task.title?.length).toBeLessThanOrEqual(120); - expect(JSON.stringify(envelope)).not.toContain("full prompt"); - expect(JSON.stringify(envelope)).not.toContain("raw transcript"); - expect(JSON.stringify(envelope)).not.toContain("sk-secret-profile-reference"); - }); +function sequenceNow(...values: Date[]): () => Date { + let index = 0; + return () => values[Math.min(index++, values.length - 1)] ?? REQUEST_AT; +} - test("marks one failed source partial and retains available records", async () => { - const { runner } = fakeRunner({ - claude: { - stdout: "", - stderr: "", - exitCode: null, - failure: "not-found" - } +function normalizedAgent(overrides: Partial = {}): AgentRecord { + const id = overrides.run_id ?? uuid(1); + return { + id: `todos:${id}`, + provider: "todos", + run_id: id, + status: "running", + active: true, + started_at: "2026-07-23T10:00:00.000Z", + updated_at: "2026-07-23T11:00:00.000Z", + worktree: null, + branch: null, + last_tool_call: { name: null, at: null, summary: null }, + goal: { id: null, title: null, status: null }, + task: { id, short_id: "E-00104", title: null, status: "running" }, + profile: { alias: null }, + freshness_at: "2026-07-23T11:00:00.000Z", + gaps: [], + ...overrides + }; +} + +describe("side-effect-free provider selection", () => { + test("does not invoke mutating installed CLIs when no safe source is configured", async () => { + const http = fakeHttp(listResult([])); + const envelope = await collectAgentVisibility({ + env: {}, + httpRunner: http.runner, + now: fixedNow }); - const envelope = await collectAgentVisibility({ runner, now: () => NOW }); + expect(http.calls).toHaveLength(0); expect(envelope.partial).toBe(true); - expect(envelope.sources.find(({ provider }) => provider === "claude")).toEqual({ - provider: "claude", - status: "unavailable", - freshness_at: null, - error: { - code: "provider-command-unavailable", - message: "claude command is not installed or discoverable." + expect(envelope.agents).toEqual([]); + expect(envelope.sources.map(({ provider, status }) => [provider, status])).toEqual([ + ["codewith", "unavailable"], + ["claude", "unavailable"], + ["todos", "unavailable"] + ]); + expect(envelope.sources.every(({ coverage }) => coverage.complete === false)).toBe(true); + }); + + test("keeps Todos list unavailable because no side-effect-free source limit exists", async () => { + const http = fakeHttp(listResult([task(1)])); + const envelope = await collectAgentVisibility({ + env: TODOS_ENV, + httpRunner: http.runner, + now: sequenceNow(REQUEST_AT, OBSERVED_AT) + }); + + expect(http.calls).toHaveLength(0); + const source = envelope.sources.find(({ provider }) => provider === "todos"); + expect(source?.status).toBe("unavailable"); + expect(source?.error?.code).toBe("side-effect-free-source-limit-unavailable"); + expect(source?.coverage).toEqual({ + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }); + }); + + test("rejects credential-bearing or signed API base URLs without executing a request", async () => { + for (const baseUrl of [ + "https://user:pass@todos.example.test", + "https://todos.example.test?signature=value", + "https://todos.example.test#credential", + "http://todos.example.test" + ]) { + const http = fakeHttp(listResult([])); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: { TODOS_URL: baseUrl }, + httpRunner: http.runner, + now: fixedNow + }); + expect(http.calls).toHaveLength(0); + expect(response.exitCode).toBe(5); + expect(response.stdout).not.toContain("signature"); + expect(response.stdout).not.toContain("credential"); + expect(response.stdout).not.toContain("user"); + } + }); + + test("black-box CLI leaves provider homes untouched when safe APIs are absent", () => { + const root = mkdtempSync(join(tmpdir(), "tai-agents-side-effects-")); + const environment = { ...process.env }; + delete environment.TODOS_URL; + delete environment.TODOS_API_URL; + delete environment.TODOS_API_KEY; + environment.HOME = root; + environment.XDG_CONFIG_HOME = join(root, "xdg-config"); + environment.XDG_DATA_HOME = join(root, "xdg-data"); + environment.XDG_CACHE_HOME = join(root, "xdg-cache"); + + try { + const child = spawnSync( + process.execPath, + ["run", "src/cli/index.ts", "agents", "--json"], + { + cwd: process.cwd(), + env: environment, + encoding: "utf8", + timeout: 5_000 + } + ); + expect(child.status).toBe(3); + expect(JSON.parse(child.stdout).error.code).toBe("all-sources-unavailable"); + expect(existsSync(join(root, ".codewith"))).toBe(false); + expect(existsSync(join(root, ".claude"))).toBe(false); + expect(existsSync(join(root, ".hasna", "todos"))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("strict safe-output normalization", () => { + test("omits all uncontrolled text, paths, credential metadata, and unsafe aliases", async () => { + const hostile = [ + "X-API-Key secret-value", + "Authorization Bearer secret-value", + "--token secret-value", + "AWS_SECRET_ACCESS_KEY secret-value", + "https://user:pass@example.test/path?signature=secret#fragment", + "/home/private-user/.env", + "\u009b\u202e\u2066\u200b\u2028\u2029" + ].join(" "); + const hostileTask = task(1, { + title: hostile, + working_dir: hostile, + branch: hostile, + account_id: uuid(99), + profile_alias: "prod-secret", + metadata: { + profile_alias: "prod-secret", + last_tool: hostile, + goal_title: hostile } }); - expect(envelope.agents.some(({ provider }) => provider === "codewith")).toBe(true); - expect(envelope.agents.some(({ provider }) => provider === "todos")).toBe(true); + const http = fakeHttp(exactResult(hostileTask)); + const json = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const human = await runAgentsCli(["show", `todos:${uuid(1)}`], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(hostileTask)).runner, + now: fixedNow + }); + + for (const output of [json.stdout, human.stdout]) { + expect(output).not.toContain("secret-value"); + expect(output).not.toContain("private-user"); + expect(output).not.toContain("signature"); + expect(output).not.toContain("fragment"); + expect(output).not.toContain("prod-secret"); + expect(output).not.toContain(uuid(99)); + expect(output).not.toMatch(/[\u0080-\u009f\u200b\u2028\u2029\u202a-\u202e\u2066-\u2069]/u); + } + + const envelope = JSON.parse(json.stdout); + const agent = envelope.agents[0]; + expect(agent.task.title).toBeNull(); + expect(agent.worktree).toBeNull(); + expect(agent.branch).toBeNull(); + expect(agent.profile.alias).toBeNull(); + expect(agent.gaps).toEqual( + expect.arrayContaining(["task.title", "worktree", "branch", "profile.alias"]) + ); + }); + + test("allows only an explicit safe configured profile alias form", async () => { + const safe = fakeHttp(exactResult(task(1, { profile_alias: "account001" }))); + const unsafe = fakeHttp(exactResult(task(1, { profile_alias: "account-id-123" }))); + + const safeResponse = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: safe.runner, + now: fixedNow + }); + const unsafeResponse = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: unsafe.runner, + now: fixedNow + }); + const safeEnvelope = JSON.parse(safeResponse.stdout); + const unsafeEnvelope = JSON.parse(unsafeResponse.stdout); + + expect(safeEnvelope.agents[0]?.profile.alias).toBe("account001"); + expect(unsafeEnvelope.agents[0]?.profile.alias).toBeNull(); + expect(JSON.stringify(unsafeEnvelope)).not.toContain("account-id-123"); + }); + + test("withholds secret-like opaque IDs instead of projecting them", async () => { + const secretLikeId = ["glpat", "abcdefghijklmnopqrst"].join("-"); + const http = fakeHttp( + exactResult({ + ...task(1), + id: secretLikeId + }) + ); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.agents).toHaveLength(0); + expect(JSON.stringify(envelope)).not.toContain(secretLikeId); + expect(envelope.sources[0].error.code).toBe("provider-identity-mismatch"); + }); + + test("never projects raw HTTP failure bodies as diagnostics", async () => { + const raw = JSON.stringify({ + error: "Authorization Bearer secret-value", + password: "secret-value", + account_id: uuid(99) + }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp({ status: 500, body: raw }).runner, + now: fixedNow + }); + + expect(response.exitCode).toBe(5); + expect(response.stdout).not.toContain("secret-value"); + expect(response.stdout).not.toContain(uuid(99)); + expect(JSON.parse(response.stdout).sources[0].error.code).toBe("provider-http-error"); + }); + + test("human formatting revalidates normalized fields instead of trusting callers", () => { + const hostile = "secret-value\u202e"; + const malicious = normalizedAgent({ + id: hostile, + run_id: hostile, + status: hostile, + active: true, + updated_at: "9999-12-31T23:59:59.000Z", + freshness_at: "9999-12-31T23:59:59.000Z", + task: { id: hostile, short_id: hostile, title: hostile, status: hostile }, + profile: { alias: hostile }, + gaps: [hostile] + }); + const output = formatAgentsTable({ + schema_version: 1, + generated_at: REQUEST_AT.toISOString(), + partial: true, + agents: [malicious], + sources: [ + { + provider: "todos", + status: "partial", + freshness_at: REQUEST_AT.toISOString(), + coverage: { + complete: false, + provider_records: 1, + projected_records: 1, + dropped_records: 0 + }, + error: { code: hostile, message: hostile } + } + ] + }); + + expect(output).not.toContain("secret-value"); + expect(output).not.toContain("9999"); + expect(output).not.toContain("\u202e"); + expect(output).toContain("inactive:unknown"); + expect(output).toContain("diagnostic-withheld"); }); +}); - test("withholds secret-like identifiers and sensitive configuration paths", async () => { - const secretId = SECRET_LIKE_GITHUB_TOKEN; - const { runner } = fakeRunner({ - codewith: result({ - data: [ - { agentId: secretId, status: "running" }, - { agentId: "safe-run", status: "running" } - ] +describe("truthful state and completeness", () => { + test("missing or unsupported status and invalid future timestamps fail closed", async () => { + for (const hostileTask of [ + task(1, { + status: undefined, + started_at: "9999-12-31T23:59:59.000Z", + updated_at: "9999-12-31T23:59:59.000Z" + }), + task(2, { + status: "mystery\u202e", + started_at: "not-a-date", + updated_at: "not-a-date" }), - claude: result([ + task(3, { + status: "unknown", + started_at: "2026-07-23T12:00:01.000Z", + updated_at: "2026-07-23T12:00:01.000Z" + }) + ]) { + const response = await runAgentsCli( + ["show", `todos:${String(hostileTask.id)}`, "--json"], { - sessionId: "claude-safe", - status: "idle", - cwd: "/home/operator/.codewith/auth" + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(hostileTask)).runner, + now: fixedNow } - ]), - todos: result([]) - }); - const envelope = await collectAgentVisibility({ runner, now: () => NOW }); - - expect(envelope.sources.find(({ provider }) => provider === "codewith")?.status).toBe("partial"); - expect(envelope.agents.some(({ run_id }) => run_id === secretId)).toBe(false); - const claude = envelope.agents.find(({ id }) => id === "claude:claude-safe"); - expect(claude?.worktree).toBeNull(); - expect(claude?.gaps).toContain("worktree unavailable from Claude agents surface"); - expect(JSON.stringify(envelope)).not.toContain(".codewith/auth"); - expect(JSON.stringify(envelope)).not.toContain(secretId); + ); + const envelope = JSON.parse(response.stdout); + const agent = envelope.agents[0]; + expect(agent.status).toBe("unknown"); + expect(agent.active).toBe(false); + expect(agent.started_at).toBeNull(); + expect(agent.updated_at).toBeNull(); + expect(agent.freshness_at).toBeNull(); + expect(agent.gaps).toEqual( + expect.arrayContaining(["status", "started_at", "updated_at", "freshness_at"]) + ); + expect(response.stdout).not.toContain("mystery"); + expect(response.stdout).not.toContain("9999"); + } + }); + + test("every unavailable normalized field has one stable named gap", async () => { + const http = fakeHttp( + exactResult( + task(1, { + short_id: "unsafe short id", + started_at: null, + updated_at: null, + profile_alias: null + }) + ) + ); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + const agent = envelope.agents[0]; + + expect(agent?.gaps).toEqual([ + "branch", + "freshness_at", + "goal.id", + "goal.status", + "goal.title", + "last_tool_call.at", + "last_tool_call.name", + "last_tool_call.summary", + "profile.alias", + "started_at", + "task.short_id", + "task.title", + "updated_at", + "worktree" + ]); }); - test("reports bounded-output failures without exposing captured provider text", async () => { - const { runner } = fakeRunner({ - todos: { - stdout: "raw transcript should be discarded", - stderr: "Authorization: Bearer should-not-leak", - exitCode: null, - failure: "output-limit" + test("does not project unassigned or merely assigned Todos tasks as agents", async () => { + const cases = [ + { value: task(1, { agent_id: undefined }), exitCode: 5 }, + { + value: task(2, { agent_id: undefined, assigned_to: "account001" }), + exitCode: 5 + }, + { + value: task(3, { session_id: uuid(103), agent_id: undefined }), + exitCode: 0 + }, + { + value: task(4, { + agent_id: undefined, + locked_by: "lease-owner", + lock_expires_at: "2026-07-23T12:10:00.000Z" + }), + exitCode: 0 + }, + { + value: task(5, { + agent_id: undefined, + locked_by: "expired-owner", + lock_expires_at: "2026-07-23T11:59:00.000Z" + }), + exitCode: 5 } + ]; + for (const item of cases) { + const response = await runAgentsCli( + ["show", `todos:${String(item.value.id)}`, "--json"], + { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(item.value)).runner, + now: fixedNow + } + ); + expect(response.exitCode).toBe(item.exitCode); + } + }); + + test("dedupe selects newest valid observation before status and ties canonically", () => { + const olderActive = normalizedAgent({ + run_id: uuid(1), + id: `todos:${uuid(1)}`, + status: "running", + active: true, + updated_at: "2026-07-23T10:00:00.000Z", + freshness_at: "2026-07-23T10:00:00.000Z", + profile: { alias: "account001" } + }); + const newerTerminal = normalizedAgent({ + run_id: uuid(1), + id: `todos:${uuid(1)}`, + status: "completed", + active: false, + updated_at: "2026-07-23T11:00:00.000Z", + freshness_at: "2026-07-23T11:00:00.000Z", + profile: { alias: "account002" } + }); + const tieA = normalizedAgent({ + run_id: uuid(2), + id: `todos:${uuid(2)}`, + profile: { alias: "account001" } + }); + const tieB = normalizedAgent({ + run_id: uuid(2), + id: `todos:${uuid(2)}`, + profile: { alias: "account002" } + }); + + expect(dedupeAgents([olderActive, newerTerminal])).toEqual([newerTerminal]); + expect(dedupeAgents([newerTerminal, olderActive])).toEqual([newerTerminal]); + expect(dedupeAgents([tieA, tieB])).toEqual(dedupeAgents([tieB, tieA])); + }); + + test("unsupported Todos list reports unknown coverage instead of capturing a stream", async () => { + const http = fakeHttp(listResult(Array.from({ length: 201 }, (_, index) => task(index + 1)), 250)); + const envelope = await collectAgentVisibility({ + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow }); - const envelope = await collectAgentVisibility({ runner, now: () => NOW }); const source = envelope.sources.find(({ provider }) => provider === "todos"); - expect(source?.status).toBe("error"); - expect(source?.error?.code).toBe("provider-output-limit"); - expect(JSON.stringify(envelope)).not.toContain("raw transcript"); - expect(JSON.stringify(envelope)).not.toContain("should-not-leak"); + expect(http.calls).toHaveLength(0); + expect(envelope.agents).toHaveLength(0); + expect(envelope.partial).toBe(true); + expect(source?.status).toBe("unavailable"); + expect(source?.error?.code).toBe("side-effect-free-source-limit-unavailable"); + expect(source?.coverage).toEqual({ + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }); + }); + + test("list hard maximum is rejected before any source operation", async () => { + const http = fakeHttp(listResult([])); + const response = await runAgentsCli(["--limit", "201", "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + + expect(response.exitCode).toBe(2); + expect(JSON.parse(response.stdout).error.code).toBe("invalid-arguments"); + expect(http.calls).toHaveLength(0); }); - test("uses exactly one bounded command per provider and never does per-record calls", async () => { - const { runner, calls } = fakeRunner(); - await collectAgentVisibility({ runner, now: () => NOW }); - - expect(calls).toHaveLength(3); - expect(calls.map(({ provider }) => provider)).toEqual(["codewith", "claude", "todos"]); - expect(calls.find(({ provider }) => provider === "codewith")?.args).toEqual([ - "agent", - "list", - "--json", - "--limit", - "200" - ]); - expect(calls.find(({ provider }) => provider === "claude")?.args).toEqual(["agents", "--json"]); - expect(calls.find(({ provider }) => provider === "todos")?.args).toEqual(["active", "--json"]); + test("source freshness is the actual completion observation, not request start", async () => { + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(task(1))).runner, + now: sequenceNow(REQUEST_AT, OBSERVED_AT) + }); + const envelope = JSON.parse(response.stdout); + const source = envelope.sources[0]; + + expect(envelope.generated_at).toBe(REQUEST_AT.toISOString()); + expect(source?.freshness_at).toBe(OBSERVED_AT.toISOString()); }); }); -describe("agent visibility CLI", () => { - test("emits the stable JSON schema shape", async () => { - const { runner } = fakeRunner(); - const response = await runAgentsCli(["--json", "--limit", "2"], { runner, now: () => NOW }); +describe("exact lookup and CLI semantics", () => { + test("exact show queries only the selected provider with one targeted GET", async () => { + const target = task(201); + const http = fakeHttp((request) => { + expect(new URL(request.url).pathname).toBe(`/v1/tasks/${uuid(201)}`); + expect(new URL(request.url).search).toBe(""); + return exactResult(target); + }); + const response = await runAgentsCli(["show", `todos:${uuid(201)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); const envelope = JSON.parse(response.stdout); expect(response.exitCode).toBe(0); - expect(Object.keys(envelope).sort()).toEqual([ - "agents", - "generated_at", - "partial", - "schema_version", - "sources" + expect(http.calls).toHaveLength(1); + expect(envelope.sources).toHaveLength(1); + expect(envelope.sources[0].provider).toBe("todos"); + expect(envelope.agents[0].id).toBe(`todos:${uuid(201)}`); + }); + + test("selected-source unavailability is incomplete, never false not-found", async () => { + const http = fakeHttp(listResult([task(1)])); + const response = await runAgentsCli(["show", `claude:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources).toHaveLength(1); + expect(envelope.sources[0].provider).toBe("claude"); + expect(http.calls).toHaveLength(0); + }); + + test("only an exact complete 404 becomes agent-not-found", async () => { + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp({ status: 404, body: '{"error":"not found"}' }).runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(4); + expect(envelope.error.code).toBe("agent-not-found"); + expect(envelope.sources[0].coverage.complete).toBe(true); + }); + + test("an exact task without agent provenance is incomplete, not absent", async () => { + const unassigned = task(1, { agent_id: undefined, assigned_to: "account001" }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(unassigned)).runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources[0].error.code).toBe("agent-provenance-missing"); + }); + + test("show rejects --limit and malformed or secret-like IDs before provider execution", async () => { + for (const args of [ + ["show", `todos:${uuid(1)}`, "--limit", "1", "--json"], + ["show", "todos:not-a-safe-id", "--json"], + ["show", ["todos:glpat", "abcdefghijklmnopqrst"].join("-"), "--json"] + ]) { + const http = fakeHttp(exactResult(task(1))); + const response = await runAgentsCli(args, { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + expect(response.exitCode).toBe(2); + expect(http.calls).toHaveLength(0); + } + }); + + test("JSON v1 and human output expose stable shape and explicit warnings", async () => { + const json = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(task(1))).runner, + now: fixedNow + }); + const envelope = JSON.parse(json.stdout); + + expect(json.exitCode).toBe(0); + expect(envelope.schema_version).toBe(AGENTS_SCHEMA_VERSION); + expect(Object.keys(envelope.sources[0]).sort()).toEqual([ + "coverage", + "error", + "freshness_at", + "provider", + "status" + ]); + expect(Object.keys(envelope.sources[0].coverage).sort()).toEqual([ + "complete", + "dropped_records", + "projected_records", + "provider_records" ]); - expect(envelope.agents).toHaveLength(2); expect(Object.keys(envelope.agents[0]).sort()).toEqual([ "active", "branch", @@ -237,105 +689,143 @@ describe("agent visibility CLI", () => { "updated_at", "worktree" ]); - expect(Object.keys(envelope.sources[0]).sort()).toEqual([ - "freshness_at", - "provider", - "status" - ]); + expect(formatAgentsTable(envelope)).toContain("WARNING incomplete sources:"); }); - test("renders a compact human table and explicit partial warning", async () => { - const { runner } = fakeRunner({ - claude: { - stdout: "", - stderr: "Authorization: Bearer should-not-leak", - exitCode: 1 - } - }); - const response = await runAgentsCli([], { runner, now: () => NOW }); + test("all unavailable sources return a stable nonzero JSON error", async () => { + const response = await runAgentsCli(["--json"], { env: {}, now: fixedNow }); + const envelope = JSON.parse(response.stdout); - expect(response.exitCode).toBe(0); + expect(response.exitCode).toBe(3); + expect(envelope.error.code).toBe("all-sources-unavailable"); + expect(envelope.partial).toBe(true); + }); + + test("all unavailable sources still render the compact human table and warnings", async () => { + const response = await runAgentsCli([], { env: {}, now: fixedNow }); + + expect(response.exitCode).toBe(3); expect(response.stdout).toContain("STATUS"); - expect(response.stdout).toContain("PROVIDER/RUN"); - expect(response.stdout).toContain("TASK/GOAL"); - expect(response.stdout).toContain("WARNING partial sources: claude=error:provider-nonzero-exit"); - expect(response.stdout).not.toContain("should-not-leak"); - expect(formatAgentsTable(JSON.parse((await runAgentsCli(["--json"], { runner, now: () => NOW })).stdout))).toContain( - "WARNING partial sources" + expect(response.stdout).toContain("WORKTREE"); + expect(response.stdout).toContain("No safely projectable agents found."); + expect(response.stdout).toContain("WARNING incomplete sources:"); + expect(response.stderr).toBe( + "error: No side-effect-free authoritative agent source is configured." ); }); +}); - test("returns a machine-readable malformed ID error without provider execution", async () => { - const { runner, calls } = fakeRunner(); - const response = await runAgentsCli(["show", "bad", "--json"], { runner, now: () => NOW }); - const envelope = JSON.parse(response.stdout); +describe("real process wall-clock and byte bounds", () => { + test("forces resolution for a direct hang", async () => { + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + timeoutMs: 100 + }); - expect(response.exitCode).toBe(2); - expect(envelope.error.code).toBe("invalid-agent-id"); - expect(envelope.agents).toEqual([]); - expect(calls).toHaveLength(0); + expect(result.failure).toBe("timeout"); + expect(performance.now() - started).toBeLessThan(1_000); }); - test("returns a stable unknown ID error after one call to each source", async () => { - const { runner, calls } = fakeRunner(); - const response = await runAgentsCli(["show", "codewith:missing", "--json"], { - runner, - now: () => NOW + test("terminates the process group when a descendant retains stdio", async () => { + const script = [ + 'const { spawn } = require("node:child_process");', + 'spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });', + "setInterval(() => {}, 1000);" + ].join(""); + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", script], + timeoutMs: 150 }); - const envelope = JSON.parse(response.stdout); - expect(response.exitCode).toBe(4); - expect(envelope.error.code).toBe("agent-not-found"); - expect(calls).toHaveLength(3); - expect(calls.flatMap(({ args }) => args)).not.toContain("missing"); + expect(result.failure).toBe("timeout"); + expect(performance.now() - started).toBeLessThan(1_000); }); - test("returns nonzero when every provider fails", async () => { - const failure: ProviderCommandResult = { - stdout: "", - stderr: "provider unavailable", - exitCode: 1 - }; - const { runner } = fakeRunner({ - codewith: failure, - claude: failure, - todos: failure - }); - const response = await runAgentsCli(["--json"], { runner, now: () => NOW }); - const envelope = JSON.parse(response.stdout); + test("preserves a bounded nonzero result without projecting stderr", async () => { + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", 'process.stderr.write("sensitive stderr"); process.exit(7)'], + timeoutMs: 1_000, + maxStderrBytes: 32 + }); - expect(response.exitCode).toBe(3); - expect(envelope.error.code).toBe("all-sources-failed"); - expect(envelope.partial).toBe(true); - expect(envelope.sources.every(({ status }: { status: string }) => status === "error")).toBe(true); + expect(result.exitCode).toBe(7); + expect(result.failure).toBeUndefined(); + expect(result.stderr.length).toBeLessThanOrEqual(32); }); - test("rejects limits above the hard maximum", async () => { - const { runner, calls } = fakeRunner(); - const response = await runAgentsCli(["--limit", "201", "--json"], { runner, now: () => NOW }); - const envelope = JSON.parse(response.stdout); + test("terminates the process group on stdout byte overflow", async () => { + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", 'process.stdout.write("x".repeat(4096)); setInterval(() => {}, 1000)'], + timeoutMs: 2_000, + maxStdoutBytes: 128 + }); - expect(response.exitCode).toBe(2); - expect(envelope.error.code).toBe("invalid-arguments"); - expect(calls).toHaveLength(0); + expect(result.failure).toBe("output-limit"); + expect(performance.now() - started).toBeLessThan(1_000); }); - test("applies the default result limit of 50", async () => { - const manyTasks = Array.from({ length: 60 }, (_, index) => ({ - id: `task-${String(index).padStart(3, "0")}`, - title: `Task ${index}`, - status: "in_progress", - updated_at: new Date(NOW.getTime() - index * 1000).toISOString() - })); - const { runner } = fakeRunner({ - codewith: result({ data: [] }), - claude: result([]), - todos: result(manyTasks) - }); - const response = await runAgentsCli(["--json"], { runner, now: () => NOW }); - const envelope = JSON.parse(response.stdout); + test("terminates the process group on stderr byte overflow", async () => { + const started = performance.now(); + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", 'process.stderr.write("x".repeat(4096)); setInterval(() => {}, 1000)'], + timeoutMs: 2_000, + maxStderrBytes: 128 + }); - expect(response.exitCode).toBe(0); - expect(envelope.agents).toHaveLength(50); + expect(result.failure).toBe("output-limit"); + expect(performance.now() - started).toBeLessThan(1_000); + }); +}); + +describe("real HTTP wall-clock and byte bounds", () => { + test("aborts and resolves a direct HTTP hang at the wall-clock deadline", async () => { + const server = Bun.serve({ + port: 0, + fetch: async () => await new Promise(() => undefined) + }); + const started = performance.now(); + try { + const result = await runBoundedHttpRequest({ + provider: "todos", + method: "GET", + url: `http://127.0.0.1:${server.port}/v1/tasks/${uuid(1)}`, + headers: { Accept: "application/json" }, + timeoutMs: 100, + maxBytes: 1024 + }); + expect(result.failure).toBe("timeout"); + expect(performance.now() - started).toBeLessThan(1_000); + } finally { + server.stop(true); + } + }); + + test("aborts an HTTP response at the byte cap", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("x".repeat(4096)) + }); + try { + const result = await runBoundedHttpRequest({ + provider: "todos", + method: "GET", + url: `http://127.0.0.1:${server.port}/v1/tasks/${uuid(1)}`, + headers: { Accept: "application/json" }, + timeoutMs: 1_000, + maxBytes: 128 + }); + expect(result.failure).toBe("output-limit"); + expect(result.body).toBe(""); + } finally { + server.stop(true); + } }); }); From 7ad8e0a1a53f6b1b238726e984298ec43184b751 Mon Sep 17 00:00:00 2001 From: andreihasna Date: Thu, 23 Jul 2026 19:54:28 +0300 Subject: [PATCH 3/3] fix(cli): close final agent visibility gaps --- README.md | 10 +- docs/architecture.md | 8 +- src/agents.ts | 713 ++++++++++++++++++------------------------- tests/agents.test.ts | 487 ++++++++++++++++++++++------- 4 files changed, 689 insertions(+), 529 deletions(-) diff --git a/README.md b/README.md index 208a8d5..61b5da0 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,11 @@ tai run "ls -la" --yes `tai agents [--limit <1-200>]` is a stateless, read-only projection of independently safe provider surfaces. The default output limit is 50. `tai agents show :` performs an exact lookup against only the selected provider. Add `--json` to either form for the versioned machine contract. `--limit` is rejected for exact lookup. -TAI does not invoke the installed `codewith agent list`, `claude agents`, or `todos active` routes: those routes can create configuration or database files, change permissions, or clean expired locks. Codewith and Claude therefore report `side-effect-free-surface-unavailable` until those providers expose a safe structured read API. Todos list reports `side-effect-free-source-limit-unavailable`: its task-list implementation cleans expired locks, while its agent-list API has no authoritative source limit. TAI will not capture either stream. +TAI does not invoke the installed `codewith agent list`, `claude agents`, or `todos active` routes: those routes can create configuration or database files, change permissions, or clean expired locks. It also does not call the authenticated Todos `/v1` GET surface because the provider initializes database schema before request dispatch. Codewith, Claude, and exact Todos therefore report `side-effect-free-surface-unavailable` until those providers expose a demonstrably side-effect-free structured read API. Todos list reports `side-effect-free-source-limit-unavailable`: its task-list implementation cleans expired locks, while its agent-list API has no authoritative source limit. -When `TODOS_URL` points to an HTTPS API (or an HTTP loopback API), exact Todos lookup can use the side-effect-free `GET /v1/tasks/` resource. `TODOS_API_KEY` is used only as a request header and is never projected. Without a safe list surface, `tai agents` currently returns a structured unavailable envelope and nonzero exit; it does not pretend empty or complete coverage. +No current provider surface satisfies the full read-only proof, so `tai agents` returns a structured unavailable envelope and nonzero exit without consulting provider configuration, credentials, HTTP endpoints, or local state. Exact show still selects only the encoded provider, but an unsupported source returns `agent-lookup-incomplete` without performing an operation. Configuration such as `TODOS_URL`, including loopback URLs, does not enable Todos visibility. -Missing providers fail soft while any independently safe source responds. If every source is unavailable or errors, the command exits nonzero. Exact lookup returns `agent-not-found` only after a complete targeted 404; unavailable, truncated, unproven, or incomplete evidence returns `agent-lookup-incomplete`. A Todos task is projected only when it contains authoritative agent, session, runner, or live-lease provenance. Assignment or `in_progress` status alone is insufficient. +Missing providers fail soft while any independently safe source responds. If every source is unavailable or errors, the command exits nonzero. Exact lookup may return `agent-not-found` only after a future safe provider surface proves structured, canonical absence; HTTP status, HTML, redirects, partial responses, malformed bodies, unavailable, truncated, or otherwise unproven evidence remains `agent-lookup-incomplete`. A future Todos projection must follow authoritative provider lease ownership and expiry semantics; assignment, task status, or a printable `agent_id` alone is insufficient. JSON v1 has these stable fields: @@ -52,9 +52,9 @@ JSON v1 has these stable fields: - Agent context: `worktree`, `branch`, `last_tool_call: {name, at, summary}`, `goal: {id, title, status}`, `task: {id, short_id, title, status}`, and `profile: {alias}`. - `gaps` is a sorted array of stable field names for every missing normalized value. Unavailable or withheld fields remain `null`; they are never inferred from prompts, transcripts, paths, matching text, or task assignment. -Output is allowlisted rather than denylist-redacted. Run and task IDs must be canonical UUIDs, task short IDs use a narrow `E-00104`-style form, statuses come from a closed set, and profile aliases use the configured `accountNNN` form. Provider-controlled titles, paths, branches, last-tool text, goal text, raw diagnostics, account IDs, credential labels or values, signed URI components, query or fragment values, and nonprinting Unicode controls are omitted. Human cells are truncated by grapheme cluster. +Output is allowlisted rather than denylist-redacted. Run and task IDs must be canonical lowercase UUIDs, task short IDs use a narrow `E-00104`-style form, statuses use an explicit own closed set, and profile aliases use the configured `accountNNN` form. Timestamps must be strict RFC3339 with an explicit timezone and are emitted as canonical UTC; ambiguous locale strings and missing, invalid, or future observations fail closed. Future live-agent sources must also reject stale liveness evidence. Provider-controlled titles, paths, branches, last-tool text, goal text, raw diagnostics, account IDs, credential labels or values, signed URI components, query or fragment values, and nonprinting Unicode controls are omitted. Human cells are truncated by grapheme cluster. -Exact Todos responses are bounded to 1 MiB and five seconds. List coverage remains explicitly unknown until the provider exposes a side-effect-free source limit. Future safe list sources must use the default output limit of 50 and hard maximum of 200, and any source or result truncation must set `partial: true` with known or unknown dropped coverage. TAI never persists agent data, initializes a provider, mutates locks or tasks, inspects transcripts or prompts, calls a provider once per result, switches profiles, or owns agent lifecycle state. +List coverage remains explicitly unknown until a provider exposes a side-effect-free source limit. Future safe sources may perform at most one bounded operation per selected source, must use the default output limit of 50 and hard maximum of 200, and must mark any source or result truncation `partial: true` with known or unknown dropped coverage. TAI never persists agent data, initializes a provider, mutates locks or tasks, inspects transcripts or prompts, calls a provider once per result, switches profiles, or owns agent lifecycle state. ## Provider Routing diff --git a/docs/architecture.md b/docs/architecture.md index a4507d4..c7d1fd3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -45,13 +45,13 @@ Model IDs are intentionally configuration-driven because availability is time-se The `tai agents` surface is separate from model routing. It is a stateless, read-only facade over provider-owned structured APIs that are demonstrably side-effect free. It does not invoke installed Codewith, Claude, or Todos list commands because those routes can initialize state, change permissions, create databases, or mutate expired task locks. -Codewith and Claude remain explicit unavailable sources until they expose a safe structured read surface. Todos list is also unavailable: its task-list implementation performs expired-lock cleanup, and its agent-list API has no authoritative source-level limit. A configured Todos API may perform one exact task `GET`, with a one-MiB response cap and a five-second wall-clock bound. Exact show calls only its selected provider. Absence is reported only after a complete targeted 404; unavailable, truncated, or unproven results remain incomplete. +Codewith and Claude remain explicit unavailable sources until they expose a safe structured read surface. Todos list is also unavailable: its task-list implementation performs expired-lock cleanup, and its agent-list API has no authoritative source-level limit. Exact Todos is unavailable because authenticated `/v1` dispatch initializes provider database schema even for GET requests. Provider configuration and credentials are never consulted. Exact show calls only its selected provider and performs zero operations when that source is unsupported. Absence may be reported only from a future proven structured response contract; HTTP status, HTML, redirects, partial responses, malformed bodies, unavailable, truncated, or unproven results remain incomplete. -Normalization is omission-first. Only canonical UUIDs, narrow task short IDs, closed-set statuses, ISO timestamps within bounded clock skew, and `accountNNN` profile aliases may cross the output boundary. Provider-controlled titles, paths, branch names, tool text, goal text, raw diagnostics, URI credentials, query/fragment values, account identifiers, and nonprinting Unicode controls never do. Every null field has a stable named gap, and every source reports explicit complete/returned/dropped coverage. +Normalization is omission-first. Only canonical lowercase UUIDs, narrow task short IDs, explicit own closed-set statuses, strict RFC3339 timestamps with explicit timezone, and `accountNNN` profile aliases may cross the output boundary. Ambiguous locale timestamps and missing, invalid, or future observations fail closed; a future live-agent source must additionally reject stale liveness evidence. Provider-controlled titles, paths, branch names, tool text, goal text, raw diagnostics, URI credentials, query/fragment values, account identifiers, and nonprinting Unicode controls never do. Every null field has a stable named gap, and every source reports explicit complete/returned/dropped coverage. -Todos tasks require authoritative agent, session, runner, or live-lease provenance before projection. Assignment or task status does not establish an agent. Duplicate state is selected by newest valid observation first, then explicit status precedence and a canonical tie-breaker; unknown status and invalid or future timestamps fail closed. +A future Todos projection must use authoritative provider lease-owner and lease-expiry semantics. Assignment, task status, or a printable agent/session identifier does not establish a live agent. Duplicate state is selected by newest valid observation first, then explicit status precedence and a canonical tie-breaker. When no valid observation exists, comparison continues without `NaN`; reversed inputs produce the same winner and order. -The reusable process boundary used by provider integrations creates a process group and force-resolves on deadline or stdout/stderr byte overflow, including when descendants retain pipes. Current agent visibility uses no provider process because no installed command meets the side-effect-free contract. +The reusable process boundary used by provider integrations starts termination inside its deadline, continuously tracks Linux descendant PIDs with their process start times, kills tracked descendants even after reparenting, kills the process group and direct child, destroys retained pipes, and resolves around the configured deadline. PID start-time checks prevent a recycled PID from becoming a cleanup target. Output overflow follows the same bounded cleanup. Current agent visibility uses no provider process because no installed command meets the side-effect-free contract. TAI owns no database, daemon, cache, task assignment, authorization, WorkRun, or agent lifecycle state, and this surface contains no stop, retry, resume, profile-switch, notification, fleet, web, or TUI behavior. diff --git a/src/agents.ts b/src/agents.ts index 67aa93e..e4c8f11 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,31 +1,33 @@ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; export const AGENTS_SCHEMA_VERSION = 1 as const; export const DEFAULT_AGENTS_LIMIT = 50; export const MAX_AGENTS_LIMIT = 200; -const DEFAULT_HTTP_TIMEOUT_MS = 5_000; -const DEFAULT_HTTP_BYTES = 1024 * 1024; const DEFAULT_PROCESS_TIMEOUT_MS = 15_000; const DEFAULT_PROCESS_STDOUT_BYTES = 4 * 1024 * 1024; const DEFAULT_PROCESS_STDERR_BYTES = 16 * 1024; +const PROCESS_TERMINATION_RESERVE_MS = 250; const EARLIEST_VALID_TIME_MS = Date.parse("2000-01-01T00:00:00.000Z"); +const RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/; const PROVIDERS = ["codewith", "claude", "todos"] as const; const ACTIVE_STATUSES = new Set(["idle", "in_progress", "running"]); -const STATUS_PRECEDENCE: Readonly> = { - running: 80, - in_progress: 70, - idle: 60, - blocked: 50, - pending: 40, - succeeded: 30, - completed: 30, - failed: 20, - stopped: 10, - cancelled: 10, - unknown: 0 -}; +const STATUS_PRECEDENCE = new Map([ + ["running", 80], + ["in_progress", 70], + ["idle", 60], + ["blocked", 50], + ["pending", 40], + ["succeeded", 30], + ["completed", 30], + ["failed", 20], + ["stopped", 10], + ["cancelled", 10] +]); export type AgentProvider = (typeof PROVIDERS)[number]; export type AgentSourceStatus = "ok" | "partial" | "unavailable" | "error"; @@ -152,11 +154,6 @@ interface ProviderCollection { agents: AgentRecord[]; } -interface TodosConfig { - baseUrl: URL; - apiKey: string | null; -} - interface NormalizedStatus { value: string; active: boolean; @@ -170,7 +167,7 @@ export async function collectAgentVisibility( const generatedAt = safeNow(now); const limit = validateLimit(options.limit ?? DEFAULT_AGENTS_LIMIT); const collections = await Promise.all( - PROVIDERS.map((provider) => collectProvider(provider, null, options, now)) + PROVIDERS.map((provider) => collectProvider(provider, null)) ); const sources = collections.map(({ source }) => source); const allAgents = sortAgents(dedupeAgents(collections.flatMap(({ agents }) => agents))); @@ -232,7 +229,7 @@ export async function runAgentsCli( } if (parsed.mode === "show" && parsedId) { - const collection = await collectProvider(parsedId.provider, parsedId.runId, options, now); + const collection = await collectProvider(parsedId.provider, parsedId.runId); const envelope: AgentsEnvelope = { schema_version: AGENTS_SCHEMA_VERSION, generated_at: generatedAt, @@ -307,7 +304,7 @@ export function formatAgentsTable(envelope: AgentsEnvelope): string { ]; const rows = envelope.agents.map((agent) => { const status = safeOutputStatus(agent.status); - const active = agent.active && ACTIVE_STATUSES.has(status); + const active = isSafelyActive(agent); return [ `${active ? "active" : "inactive"}:${status}`, safeOutputAgentId(agent.id), @@ -361,8 +358,10 @@ export function dedupeAgents(agents: AgentRecord[]): AgentRecord[] { export function sortAgents(agents: AgentRecord[]): AgentRecord[] { return [...agents].sort((left, right) => { - if (left.active !== right.active) { - return left.active ? -1 : 1; + const leftActive = isSafelyActive(left); + const rightActive = isSafelyActive(right); + if (leftActive !== rightActive) { + return leftActive ? -1 : 1; } const observationOrder = compareObservationTime(left, right); if (observationOrder !== 0) { @@ -389,10 +388,24 @@ export async function runBoundedProviderProcess( let stdout = Buffer.alloc(0); let stderr = Buffer.alloc(0); let settled = false; + let terminating = false; + let deadlineTimer: ReturnType | null = null; + let cleanupTimer: ReturnType | null = null; + let descendantTimer: ReturnType | null = null; + const trackedDescendants = new Map(); + const scopeId = randomUUID(); + const cleanupGraceMs = Math.min(50, Math.max(1, Math.floor(timeoutMs / 4))); + const terminationReserveMs = + timeoutMs >= 1_000 + ? Math.min(PROCESS_TERMINATION_RESERVE_MS, Math.max(1, timeoutMs - 1)) + : cleanupGraceMs; const child = spawn(request.command, request.args, { cwd: request.cwd, detached: process.platform !== "win32", - env: request.env ?? process.env, + env: { + ...(request.env ?? process.env), + TAI_PROVIDER_PROCESS_SCOPE: scopeId + }, shell: false, stdio: ["ignore", "pipe", "pipe"] }); @@ -405,7 +418,15 @@ export async function runBoundedProviderProcess( return; } settled = true; - clearTimeout(timer); + if (deadlineTimer) { + clearTimeout(deadlineTimer); + } + if (cleanupTimer) { + clearTimeout(cleanupTimer); + } + if (descendantTimer) { + clearInterval(descendantTimer); + } resolve({ stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8"), @@ -414,10 +435,23 @@ export async function runBoundedProviderProcess( }); }; - const terminateGroup = (failure: "output-limit" | "timeout"): void => { - if (settled) { + const finishTermination = ( + failure: "output-limit" | "timeout", + knownDescendants: number[] + ): void => { + if (settled || !terminating) { return; } + const descendants = child.pid + ? [ + ...new Set([ + ...knownDescendants, + ...collectDescendantPids(child.pid) + ]) + ] + : knownDescendants; + killPids(descendants); + killTrackedPids(trackedDescendants); if (child.pid && process.platform !== "win32") { try { process.kill(-child.pid, "SIGKILL"); @@ -436,18 +470,61 @@ export async function runBoundedProviderProcess( finish(null, failure); }; - const timer = setTimeout(() => terminateGroup("timeout"), timeoutMs); + const beginTermination = (failure: "output-limit" | "timeout"): void => { + if (settled || terminating) { + return; + } + terminating = true; + if (child.pid && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGSTOP"); + } catch { + // The process group may already be gone. + } + } + if (timeoutMs >= 1_000) { + mergeTrackedPids(trackedDescendants, collectScopedProcesses(scopeId)); + } + const descendants = child.pid + ? collectDescendantPids(child.pid) + : []; + killPids(descendants); + killTrackedPids(trackedDescendants); + cleanupTimer = setTimeout( + () => finishTermination(failure, descendants), + cleanupGraceMs + ); + }; + + deadlineTimer = setTimeout( + () => beginTermination("timeout"), + Math.max(1, timeoutMs - terminationReserveMs) + ); + const trackDescendants = (): void => { + if (!child.pid || terminating) { + return; + } + for (const pid of collectDescendantPids(child.pid)) { + const startTime = readProcessStartTime(pid); + if (startTime) { + trackedDescendants.set(pid, startTime); + } + } + }; + trackDescendants(); + descendantTimer = setInterval(trackDescendants, 2); + descendantTimer.unref(); child.stdout.on("data", (chunk: Buffer) => { if (stdout.length + chunk.length > maxStdoutBytes) { - terminateGroup("output-limit"); + beginTermination("output-limit"); return; } stdout = Buffer.concat([stdout, chunk]); }); child.stderr.on("data", (chunk: Buffer) => { if (stderr.length + chunk.length > maxStderrBytes) { - terminateGroup("output-limit"); + beginTermination("output-limit"); return; } stderr = Buffer.concat([stderr, chunk]); @@ -455,306 +532,164 @@ export async function runBoundedProviderProcess( child.on("error", (error: NodeJS.ErrnoException) => { finish(null, error.code === "ENOENT" ? "not-found" : "spawn-error"); }); - child.on("close", (exitCode) => finish(exitCode)); + child.on("close", (exitCode) => { + if (!terminating) { + terminating = true; + mergeTrackedPids(trackedDescendants, collectScopedProcesses(scopeId)); + killTrackedPids(trackedDescendants); + cleanupTimer = setTimeout(() => { + finish(exitCode); + }, cleanupGraceMs); + } + }); }); } -async function collectProvider( - provider: AgentProvider, - exactRunId: string | null, - options: CollectAgentsOptions, - now: () => Date -): Promise { - if (provider === "codewith" || provider === "claude") { - return unsupportedCollection( - provider, - "side-effect-free-surface-unavailable", - `No side-effect-free structured ${provider} agent read surface is configured.` - ); - } - return await collectTodos(exactRunId, options, now); -} - -async function collectTodos( - exactRunId: string | null, - options: CollectAgentsOptions, - now: () => Date -): Promise { - if (!exactRunId) { - return unsupportedCollection( - "todos", - "side-effect-free-source-limit-unavailable", - "Todos has no side-effect-free source-level bounded list surface." - ); - } - const env = options.env ?? process.env; - const config = resolveTodosConfig(env); - if (!config) { - return unsupportedCollection( - "todos", - "side-effect-free-surface-unavailable", - "No side-effect-free Todos API is configured." - ); - } - - const url = buildTodosUrl(config.baseUrl, exactRunId); - const headers: Record = { Accept: "application/json" }; - if (config.apiKey) { - headers["x-api-key"] = config.apiKey; +function collectDescendantPids(rootPid: number): number[] { + if (process.platform !== "linux") { + return []; } - const result = await (options.httpRunner ?? runBoundedHttpRequest)({ - provider: "todos", - method: "GET", - url, - headers, - timeoutMs: DEFAULT_HTTP_TIMEOUT_MS, - maxBytes: DEFAULT_HTTP_BYTES - }); - const observedAt = safeNow(now); - - if (result.failure) { - const failureMessages: Record< - NonNullable, - AgentSourceError - > = { - "network-error": { - code: "provider-network-error", - message: "The side-effect-free Todos API request failed." - }, - "output-limit": { - code: "provider-output-limit", - message: "The side-effect-free Todos API exceeded the response byte limit." - }, - timeout: { - code: "provider-timeout", - message: "The side-effect-free Todos API exceeded the wall-clock limit." + const pending = [rootPid]; + const seen = new Set([rootPid]); + const descendants: number[] = []; + while (pending.length > 0) { + const parentPid = pending.shift(); + if (!parentPid) { + continue; + } + let children = ""; + try { + children = readFileSync( + `/proc/${parentPid}/task/${parentPid}/children`, + "utf8" + ); + } catch { + continue; + } + for (const token of children.trim().split(/\s+/)) { + const childPid = Number(token); + if ( + !Number.isSafeInteger(childPid) || + childPid <= 1 || + childPid === process.pid || + seen.has(childPid) + ) { + continue; } - }; - return failedCollection("todos", observedAt, failureMessages[result.failure]); - } - - if (exactRunId && result.status === 404) { - return { - source: { - provider: "todos", - status: "ok", - freshness_at: observedAt, - coverage: { - complete: true, - provider_records: 0, - projected_records: 0, - dropped_records: 0 - } - }, - agents: [] - }; + seen.add(childPid); + descendants.push(childPid); + pending.push(childPid); + } } + return descendants.reverse(); +} - if (result.status === null || result.status < 200 || result.status >= 300) { - return failedCollection("todos", observedAt, { - code: "provider-http-error", - message: "The side-effect-free Todos API returned a non-success status." - }); +function readProcessStartTime(pid: number): string | null { + if (process.platform !== "linux") { + return null; } - - let payload: unknown; try { - payload = JSON.parse(result.body) as unknown; + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); + return fields[19] ?? null; } catch { - return failedCollection("todos", observedAt, { - code: "provider-invalid-json", - message: "The side-effect-free Todos API returned invalid JSON." - }); + return null; } - - return normalizeTodosExact(payload, exactRunId, observedAt); } -function normalizeTodosExact( - payload: unknown, - exactRunId: string, - observedAt: string -): ProviderCollection { - if (!isRecord(payload) || !isRecord(payload.task)) { - return failedCollection("todos", observedAt, { - code: "provider-invalid-payload", - message: "The side-effect-free Todos API returned an unsupported exact record." - }); +function collectScopedProcesses(scopeId: string): Map { + const matches = new Map(); + if (process.platform !== "linux") { + return matches; } - const raw = payload.task; - if (safeUuid(raw.id) !== exactRunId) { - return failedCollection("todos", observedAt, { - code: "provider-identity-mismatch", - message: "The side-effect-free Todos API returned a different exact record." - }); - } - if (!hasTodosAgentProvenance(raw, observedAt)) { - return { - source: { - provider: "todos", - status: "partial", - freshness_at: observedAt, - coverage: { - complete: true, - provider_records: 1, - projected_records: 0, - dropped_records: 1 - }, - error: { - code: "agent-provenance-missing", - message: "The exact Todos task has no authoritative agent, session, or live lease provenance." - } - }, - agents: [] - }; + const marker = Buffer.from(`TAI_PROVIDER_PROCESS_SCOPE=${scopeId}\0`); + let entries: string[]; + try { + entries = readdirSync("/proc"); + } catch { + return matches; } - const record = normalizeTodosTask(raw, observedAt); - if (!record) { - return { - source: { - provider: "todos", - status: "partial", - freshness_at: observedAt, - coverage: { - complete: false, - provider_records: 1, - projected_records: 0, - dropped_records: 1 - }, - error: { - code: "record-withheld", - message: "The exact Todos record did not satisfy the safe-output contract." - } - }, - agents: [] - }; + for (const entry of entries) { + if (!/^[0-9]+$/.test(entry)) { + continue; + } + const pid = Number(entry); + if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) { + continue; + } + let environment: Buffer | null = null; + try { + environment = readFileSync(`/proc/${pid}/environ`); + if (!environment.includes(marker)) { + continue; + } + const startTime = readProcessStartTime(pid); + if (startTime) { + matches.set(pid, startTime); + } + } catch { + // The process may have exited or may not expose its environment. + } finally { + environment?.fill(0); + } } - const incomplete = record.gaps.length > 0; - return { - source: { - provider: "todos", - status: incomplete ? "partial" : "ok", - freshness_at: observedAt, - coverage: { - complete: true, - provider_records: 1, - projected_records: 1, - dropped_records: 0 - }, - ...(incomplete - ? { - error: { - code: "normalized-fields-unavailable", - message: "The exact Todos record has explicit unavailable normalized fields." - } - } - : {}) - }, - agents: [record] - }; + return matches; } -function normalizeTodosTask( - raw: Record, - observedAt: string -): AgentRecord | null { - const runId = safeUuid(raw.id); - if (!runId) { - return null; - } - const observedAtMs = Date.parse(observedAt); - const status = normalizeStatus(raw.status); - let startedAt = normalizeTimestamp(raw.started_at ?? raw.created_at, observedAtMs); - const updatedAt = normalizeTimestamp(raw.updated_at ?? raw.synced_at, observedAtMs); - if (startedAt && updatedAt && Date.parse(startedAt) > Date.parse(updatedAt)) { - startedAt = null; +function mergeTrackedPids( + target: Map, + source: ReadonlyMap +): void { + for (const [pid, startTime] of source) { + target.set(pid, startTime); } - const metadata = isRecord(raw.metadata) ? raw.metadata : {}; - const shortId = safeShortId(raw.short_id); - const profileAlias = safeProfileAlias(raw.profile_alias ?? metadata.profile_alias); - const gaps: string[] = []; - - if (!status.complete) gaps.push("status"); - if (!startedAt) gaps.push("started_at"); - if (!updatedAt) gaps.push("updated_at"); - gaps.push( - "worktree", - "branch", - "last_tool_call.name", - "last_tool_call.at", - "last_tool_call.summary", - "goal.id", - "goal.title", - "goal.status" - ); - if (!shortId) gaps.push("task.short_id"); - gaps.push("task.title"); - if (!status.complete) gaps.push("task.status"); - if (!profileAlias) gaps.push("profile.alias"); - if (!updatedAt) gaps.push("freshness_at"); +} - return { - id: `todos:${runId}`, - provider: "todos", - run_id: runId, - status: status.value, - active: status.active, - started_at: startedAt, - updated_at: updatedAt, - worktree: null, - branch: null, - last_tool_call: { name: null, at: null, summary: null }, - goal: { id: null, title: null, status: null }, - task: { - id: runId, - short_id: shortId, - title: null, - status: status.complete ? status.value : null - }, - profile: { alias: profileAlias }, - freshness_at: updatedAt, - gaps: [...new Set(gaps)].sort() - }; +function killTrackedPids(tracked: ReadonlyMap): void { + for (const [pid, expectedStartTime] of tracked) { + if (readProcessStartTime(pid) !== expectedStartTime) { + continue; + } + killPids([pid]); + } } -function hasTodosAgentProvenance( - raw: Record, - observedAt: string -): boolean { - const metadata = isRecord(raw.metadata) ? raw.metadata : {}; - const directValues = [ - raw.agent_id, - raw.session_id, - raw.runner_id, - metadata.agent_id, - metadata.session_id, - metadata.run_id - ]; - if (directValues.some(hasOpaqueProvenanceValue)) { - return true; +function killPids(pids: number[]): void { + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // A descendant may have exited between the process-tree snapshot and kill. + } } +} - const leaseOwner = raw.locked_by ?? metadata.locked_by; - const leaseExpiry = raw.lock_expires_at ?? metadata.lock_expires_at; - if (!hasOpaqueProvenanceValue(leaseOwner)) { - return false; +async function collectProvider( + provider: AgentProvider, + exactRunId: string | null +): Promise { + if (provider === "codewith" || provider === "claude") { + return unsupportedCollection( + provider, + "side-effect-free-surface-unavailable", + `No side-effect-free structured ${provider} agent read surface is configured.` + ); } - const expiryMs = parseTimestampMs(leaseExpiry); - const observedMs = Date.parse(observedAt); - return ( - expiryMs !== null && - expiryMs >= observedMs && - expiryMs <= observedMs + 24 * 60 * 60 * 1000 - ); + return collectTodos(exactRunId); } -function hasOpaqueProvenanceValue(value: unknown): boolean { - return ( - typeof value === "string" && - value.length >= 1 && - value.length <= 256 && - value.trim() === value && - !NONPRINTING_PATTERN.test(value) +function collectTodos(exactRunId: string | null): ProviderCollection { + if (!exactRunId) { + return unsupportedCollection( + "todos", + "side-effect-free-source-limit-unavailable", + "Todos has no side-effect-free source-level bounded list surface." + ); + } + return unsupportedCollection( + "todos", + "side-effect-free-surface-unavailable", + "Todos has no demonstrably side-effect-free structured exact-read surface." ); } @@ -763,7 +698,7 @@ function normalizeStatus(value: unknown): NormalizedStatus { return { value: "unknown", active: false, complete: false }; } const candidate = value.trim().toLowerCase(); - if (candidate === "unknown" || !(candidate in STATUS_PRECEDENCE)) { + if (candidate === "unknown" || !STATUS_PRECEDENCE.has(candidate)) { return { value: "unknown", active: false, complete: false }; } return { @@ -773,32 +708,41 @@ function normalizeStatus(value: unknown): NormalizedStatus { }; } -function normalizeTimestamp(value: unknown, observedAtMs: number): string | null { - const milliseconds = parseTimestampMs(value); +function parseTimestampMs(value: unknown): number | null { + if (typeof value !== "string") { + return null; + } + const match = RFC3339_PATTERN.exec(value); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const zone = match[8] ?? ""; if ( - milliseconds === null || - milliseconds < EARLIEST_VALID_TIME_MS || - milliseconds > observedAtMs + year < 2000 || + month < 1 || + month > 12 || + day < 1 || + day > new Date(Date.UTC(year, month, 0)).getUTCDate() || + hour > 23 || + minute > 59 || + second > 59 ) { return null; } - return new Date(milliseconds).toISOString(); -} - -function parseTimestampMs(value: unknown): number | null { - let milliseconds: number; - if (typeof value === "number" && Number.isFinite(value)) { - milliseconds = value > 10_000_000_000 ? value : value * 1000; - } else if (typeof value === "string" && value.trim() !== "") { - const numeric = Number(value); - milliseconds = Number.isFinite(numeric) - ? numeric > 10_000_000_000 - ? numeric - : numeric * 1000 - : Date.parse(value); - } else { - return null; + if (zone !== "Z") { + const zoneHour = Number(zone.slice(1, 3)); + const zoneMinute = Number(zone.slice(4, 6)); + if (zoneHour > 23 || zoneMinute > 59) { + return null; + } } + const milliseconds = Date.parse(value); return Number.isFinite(milliseconds) ? milliseconds : null; } @@ -806,11 +750,10 @@ function safeUuid(value: unknown): string | null { if (typeof value !== "string") { return null; } - const candidate = value.toLowerCase(); return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( - candidate + value ) - ? candidate + ? value : null; } @@ -828,48 +771,6 @@ function safeProfileAlias(value: unknown): string | null { return /^account[0-9]{3}$/.test(value) ? value : null; } -function resolveTodosConfig( - env: Readonly> -): TodosConfig | null { - const rawBaseUrl = env.TODOS_URL ?? env.TODOS_API_URL; - if (!rawBaseUrl) { - return null; - } - let baseUrl: URL; - try { - baseUrl = new URL(rawBaseUrl); - } catch { - return null; - } - if ( - (baseUrl.protocol !== "https:" && - !(baseUrl.protocol === "http:" && isLoopbackHostname(baseUrl.hostname))) || - baseUrl.username !== "" || - baseUrl.password !== "" || - baseUrl.search !== "" || - baseUrl.hash !== "" - ) { - return null; - } - return { - baseUrl, - apiKey: env.TODOS_API_KEY || null - }; -} - -function isLoopbackHostname(hostname: string): boolean { - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; -} - -function buildTodosUrl(baseUrl: URL, exactRunId: string): string { - const url = new URL(baseUrl.toString()); - const prefix = url.pathname.replace(/\/+$/, ""); - url.pathname = `${prefix}/v1/tasks/${encodeURIComponent(exactRunId)}`; - url.search = ""; - url.hash = ""; - return url.toString(); -} - export async function runBoundedHttpRequest( request: ProviderHttpRequest ): Promise { @@ -943,28 +844,6 @@ function unsupportedCollection( }; } -function failedCollection( - provider: AgentProvider, - observedAt: string, - error: AgentSourceError -): ProviderCollection { - return { - source: { - provider, - status: "error", - freshness_at: observedAt, - coverage: { - complete: false, - provider_records: null, - projected_records: 0, - dropped_records: null - }, - error - }, - agents: [] - }; -} - function applyResultLimitCoverage( sources: AgentSource[], allAgents: AgentRecord[], @@ -1004,7 +883,8 @@ function compareObservations(left: AgentRecord, right: AgentRecord): number { return timeOrder; } const statusOrder = - (STATUS_PRECEDENCE[right.status] ?? 0) - (STATUS_PRECEDENCE[left.status] ?? 0); + (STATUS_PRECEDENCE.get(right.status) ?? 0) - + (STATUS_PRECEDENCE.get(left.status) ?? 0); if (statusOrder !== 0) { return statusOrder; } @@ -1012,19 +892,34 @@ function compareObservations(left: AgentRecord, right: AgentRecord): number { } function compareObservationTime(left: AgentRecord, right: AgentRecord): number { - return observationTime(right) - observationTime(left); + const leftTime = observationTime(left); + const rightTime = observationTime(right); + if (leftTime === rightTime) { + return 0; + } + return leftTime > rightTime ? -1 : 1; } function observationTime(agent: AgentRecord): number { for (const value of [agent.updated_at, agent.freshness_at, agent.started_at]) { - const parsed = value ? Date.parse(value) : Number.NaN; - if (Number.isFinite(parsed)) { + const parsed = parseTimestampMs(value); + if (parsed !== null && parsed <= Date.now()) { return parsed; } } return Number.NEGATIVE_INFINITY; } +function isSafelyActive(agent: AgentRecord): boolean { + const status = normalizeStatus(agent.status); + return ( + agent.active && + status.complete && + status.active && + observationTime(agent) !== Number.NEGATIVE_INFINITY + ); +} + function canonicalRecord(agent: AgentRecord): string { return JSON.stringify(agent); } @@ -1141,9 +1036,10 @@ function formatTaskGoal(agent: AgentRecord): string { function formatAgentDetails(agent: AgentRecord, sources: AgentSource[]): string { const safeStatus = safeOutputStatus(agent.status); const safeGaps = agent.gaps.filter((gap) => SAFE_GAP_NAMES.has(gap)).sort(); + const active = isSafelyActive(agent); return [ `Agent: ${safeOutputAgentId(agent.id)}`, - `Status: ${safeStatus} (${agent.active && ACTIVE_STATUSES.has(safeStatus) ? "active" : "inactive"})`, + `Status: ${safeStatus} (${active ? "active" : "inactive"})`, `Started: ${safeOutputTimestamp(agent.started_at)}`, `Updated: ${safeOutputTimestamp(agent.updated_at)}`, "Worktree: —", @@ -1180,15 +1076,11 @@ function safeOutputStatus(value: unknown): string { } function safeOutputTimestamp(value: unknown): string { - if (typeof value !== "string") { - return "—"; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) && + const parsed = parseTimestampMs(value); + return parsed !== null && parsed >= EARLIEST_VALID_TIME_MS && - parsed <= Date.now() && - new Date(parsed).toISOString() === value - ? value + parsed <= Date.now() + ? new Date(parsed).toISOString() : "—"; } @@ -1233,13 +1125,6 @@ function positiveBound(value: number | undefined, fallback: number): number { return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : fallback; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -const NONPRINTING_PATTERN = - /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u0080-\u009f\u200b\u202a-\u202e\u2066-\u2069]/u; - const SAFE_GAP_NAMES = new Set([ "branch", "freshness_at", diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 38e9d69..57f25a6 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -11,6 +11,7 @@ import { runAgentsCli, runBoundedHttpRequest, runBoundedProviderProcess, + sortAgents, type AgentRecord, type ProviderHttpRequest, type ProviderHttpResult, @@ -88,6 +89,24 @@ function sequenceNow(...values: Date[]): () => Date { return () => values[Math.min(index++, values.length - 1)] ?? REQUEST_AT; } +function isProcessLive(pid: number): boolean { + if (process.platform === "linux") { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const state = stat.slice(stat.lastIndexOf(")") + 2).charAt(0); + return state !== "Z" && state !== "X"; + } catch { + return false; + } + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function normalizedAgent(overrides: Partial = {}): AgentRecord { const id = overrides.run_id ?? uuid(1); return { @@ -110,6 +129,277 @@ function normalizedAgent(overrides: Partial = {}): AgentRecord { }; } +describe("final repair-cycle regressions", () => { + test("never calls the authenticated Todos HTTP surface, regardless of response semantics", async () => { + for (const result of [ + exactResult(task(1)), + exactResult(task(1), 201), + exactResult(task(1), 206), + exactResult(task(1), 226), + { status: 302, body: "" }, + { status: 404, body: "not found" } + ]) { + const http = fakeHttp(result); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(http.calls).toHaveLength(0); + expect(response.exitCode).toBe(5); + expect(envelope.agents).toEqual([]); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage.complete).toBe(false); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + } + }); + + test("prototype names are not recognized statuses and remain fail-closed", () => { + for (const status of ["constructor", "__proto__"]) { + const hostile = normalizedAgent({ + id: `todos:${uuid(2)}`, + run_id: uuid(2), + status, + active: true, + started_at: null, + updated_at: null, + freshness_at: null, + task: { id: uuid(1), short_id: "E-00104", title: null, status }, + gaps: ["status", "task.status"] + }); + const output = formatAgentsTable({ + schema_version: 1, + generated_at: REQUEST_AT.toISOString(), + partial: true, + sources: [], + agents: [hostile] + }); + + expect(output).toContain("inactive:unknown"); + expect(output).not.toContain(status); + expect(hostile.gaps).toEqual(["status", "task.status"]); + const valid = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false + }); + expect(sortAgents([hostile, valid])).toEqual([valid, hostile]); + } + }); + + test("a stale printable agent_id never proves live Todos agent provenance", async () => { + const stale = task(1, { + agent_id: "printable-agent", + status: "running", + started_at: "2000-01-01T00:00:00.000Z", + updated_at: "2000-01-01T00:00:00.000Z", + locked_at: "2000-01-01T00:00:00.000Z" + }); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: TODOS_ENV, + httpRunner: fakeHttp(exactResult(stale)).runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(envelope.agents).toEqual([]); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage.complete).toBe(false); + }); + + test("rejects uppercase UUIDs as noncanonical before any provider execution", async () => { + const lowercase = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const http = fakeHttp(exactResult(task(1))); + const response = await runAgentsCli( + ["show", `todos:${lowercase.toUpperCase()}`, "--json"], + { + env: TODOS_ENV, + httpRunner: http.runner, + now: fixedNow + } + ); + + expect(response.exitCode).toBe(2); + expect(http.calls).toHaveLength(0); + expect(JSON.parse(response.stdout).agents).toEqual([]); + }); + + test("documents IPv6 loopback as unsupported without attempting a request", async () => { + const http = fakeHttp(exactResult(task(1))); + const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { + env: { TODOS_URL: "http://[::1]:43117" }, + httpRunner: http.runner, + now: fixedNow + }); + const envelope = JSON.parse(response.stdout); + + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); + }); + + test("uses status and canonical tie-breakers when every timestamp is invalid", () => { + const running = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "running", + active: true, + started_at: null, + updated_at: null, + freshness_at: null, + profile: { alias: "account002" } + }); + const completed = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false, + started_at: null, + updated_at: null, + freshness_at: null, + profile: { alias: "account001" } + }); + const first = normalizedAgent({ + id: `todos:${uuid(2)}`, + run_id: uuid(2), + status: "completed", + active: false, + started_at: null, + updated_at: null, + freshness_at: null + }); + const second = normalizedAgent({ + id: `todos:${uuid(3)}`, + run_id: uuid(3), + status: "completed", + active: false, + started_at: null, + updated_at: null, + freshness_at: null + }); + + expect(dedupeAgents([running, completed])).toEqual([running]); + expect(dedupeAgents([completed, running])).toEqual([running]); + expect(sortAgents([second, first])).toEqual([first, second]); + expect(sortAgents([first, second])).toEqual([first, second]); + }); + + test("ambiguous non-RFC3339 timestamps cannot win dedupe ordering", () => { + const ambiguous = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false, + started_at: "01/02/2026", + updated_at: "01/02/2026", + freshness_at: "01/02/2026" + }); + const untimedRunning = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "running", + active: true, + started_at: null, + updated_at: null, + freshness_at: null + }); + + expect(dedupeAgents([ambiguous, untimedRunning])).toEqual([untimedRunning]); + expect(dedupeAgents([untimedRunning, ambiguous])).toEqual([untimedRunning]); + + const future = normalizedAgent({ + id: `todos:${uuid(1)}`, + run_id: uuid(1), + status: "completed", + active: false, + started_at: "9999-12-31T23:59:59.000Z", + updated_at: "9999-12-31T23:59:59.000Z", + freshness_at: "9999-12-31T23:59:59.000Z" + }); + expect(dedupeAgents([future, untimedRunning])).toEqual([untimedRunning]); + expect(dedupeAgents([untimedRunning, future])).toEqual([untimedRunning]); + }); + + test("kills a detached grandchild before resolving the timeout", async () => { + const root = mkdtempSync(join(tmpdir(), "tai-agents-detached-grandchild-")); + const pidFile = join(root, "grandchild.pid"); + const script = [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + `const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`, + "child.unref();", + "setInterval(() => {}, 1000);" + ].join(""); + let escapedPid: number | null = null; + + try { + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", script], + timeoutMs: 200 + }); + escapedPid = Number(readFileSync(pidFile, "utf8")); + + expect(result.failure).toBe("timeout"); + expect(isProcessLive(Number(escapedPid))).toBe(false); + } finally { + if (escapedPid && Number.isSafeInteger(escapedPid)) { + try { + process.kill(escapedPid, "SIGKILL"); + } catch { + // The repaired implementation already terminated it. + } + } + rmSync(root, { recursive: true, force: true }); + } + }); + + test("kills a detached grandchild even after the direct child exits", async () => { + const root = mkdtempSync(join(tmpdir(), "tai-agents-reparented-grandchild-")); + const pidFile = join(root, "grandchild.pid"); + const script = [ + 'const { spawn } = require("node:child_process");', + 'const { writeFileSync } = require("node:fs");', + `const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });`, + `writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));`, + "child.unref();" + ].join(""); + let escapedPid: number | null = null; + + try { + const result = await runBoundedProviderProcess({ + command: process.execPath, + args: ["-e", script], + timeoutMs: 1_000 + }); + escapedPid = Number(readFileSync(pidFile, "utf8")); + + expect(result.exitCode).toBe(0); + expect(result.failure).toBeUndefined(); + expect(isProcessLive(Number(escapedPid))).toBe(false); + } finally { + if (escapedPid && Number.isSafeInteger(escapedPid)) { + try { + process.kill(escapedPid, "SIGKILL"); + } catch { + // The repaired implementation already terminated it. + } + } + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("side-effect-free provider selection", () => { test("does not invoke mutating installed CLIs when no safe source is configured", async () => { const http = fakeHttp(listResult([])); @@ -250,17 +540,13 @@ describe("strict safe-output normalization", () => { } const envelope = JSON.parse(json.stdout); - const agent = envelope.agents[0]; - expect(agent.task.title).toBeNull(); - expect(agent.worktree).toBeNull(); - expect(agent.branch).toBeNull(); - expect(agent.profile.alias).toBeNull(); - expect(agent.gaps).toEqual( - expect.arrayContaining(["task.title", "worktree", "branch", "profile.alias"]) - ); + expect(http.calls).toHaveLength(0); + expect(envelope.agents).toEqual([]); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage.complete).toBe(false); }); - test("allows only an explicit safe configured profile alias form", async () => { + test("does not project profile aliases from an unsupported provider", async () => { const safe = fakeHttp(exactResult(task(1, { profile_alias: "account001" }))); const unsafe = fakeHttp(exactResult(task(1, { profile_alias: "account-id-123" }))); @@ -277,8 +563,11 @@ describe("strict safe-output normalization", () => { const safeEnvelope = JSON.parse(safeResponse.stdout); const unsafeEnvelope = JSON.parse(unsafeResponse.stdout); - expect(safeEnvelope.agents[0]?.profile.alias).toBe("account001"); - expect(unsafeEnvelope.agents[0]?.profile.alias).toBeNull(); + expect(safe.calls).toHaveLength(0); + expect(unsafe.calls).toHaveLength(0); + expect(safeEnvelope.agents).toEqual([]); + expect(unsafeEnvelope.agents).toEqual([]); + expect(JSON.stringify(safeEnvelope)).not.toContain("account001"); expect(JSON.stringify(unsafeEnvelope)).not.toContain("account-id-123"); }); @@ -300,7 +589,10 @@ describe("strict safe-output normalization", () => { expect(response.exitCode).toBe(5); expect(envelope.agents).toHaveLength(0); expect(JSON.stringify(envelope)).not.toContain(secretLikeId); - expect(envelope.sources[0].error.code).toBe("provider-identity-mismatch"); + expect(http.calls).toHaveLength(0); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); }); test("never projects raw HTTP failure bodies as diagnostics", async () => { @@ -309,16 +601,20 @@ describe("strict safe-output normalization", () => { password: "secret-value", account_id: uuid(99) }); + const http = fakeHttp({ status: 500, body: raw }); const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { env: TODOS_ENV, - httpRunner: fakeHttp({ status: 500, body: raw }).runner, + httpRunner: http.runner, now: fixedNow }); + expect(http.calls).toHaveLength(0); expect(response.exitCode).toBe(5); expect(response.stdout).not.toContain("secret-value"); expect(response.stdout).not.toContain(uuid(99)); - expect(JSON.parse(response.stdout).sources[0].error.code).toBe("provider-http-error"); + expect(JSON.parse(response.stdout).sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); }); test("human formatting revalidates normalized fields instead of trusting callers", () => { @@ -364,48 +660,43 @@ describe("strict safe-output normalization", () => { }); describe("truthful state and completeness", () => { - test("missing or unsupported status and invalid future timestamps fail closed", async () => { - for (const hostileTask of [ - task(1, { - status: undefined, - started_at: "9999-12-31T23:59:59.000Z", - updated_at: "9999-12-31T23:59:59.000Z" + test("missing or unsupported status and invalid timestamps render fail-closed", () => { + for (const hostile of [ + normalizedAgent({ + status: "unknown", + active: true, + started_at: null, + updated_at: null, + freshness_at: null, + gaps: ["status", "started_at", "updated_at", "freshness_at"] }), - task(2, { + normalizedAgent({ status: "mystery\u202e", + active: true, started_at: "not-a-date", - updated_at: "not-a-date" - }), - task(3, { - status: "unknown", - started_at: "2026-07-23T12:00:01.000Z", - updated_at: "2026-07-23T12:00:01.000Z" + updated_at: "not-a-date", + freshness_at: "9999-12-31T23:59:59.000Z", + gaps: ["status", "started_at", "updated_at", "freshness_at"] }) ]) { - const response = await runAgentsCli( - ["show", `todos:${String(hostileTask.id)}`, "--json"], - { - env: TODOS_ENV, - httpRunner: fakeHttp(exactResult(hostileTask)).runner, - now: fixedNow - } - ); - const envelope = JSON.parse(response.stdout); - const agent = envelope.agents[0]; - expect(agent.status).toBe("unknown"); - expect(agent.active).toBe(false); - expect(agent.started_at).toBeNull(); - expect(agent.updated_at).toBeNull(); - expect(agent.freshness_at).toBeNull(); - expect(agent.gaps).toEqual( + const output = formatAgentsTable({ + schema_version: 1, + generated_at: REQUEST_AT.toISOString(), + partial: true, + sources: [], + agents: [hostile] + }); + + expect(output).toContain("inactive:unknown"); + expect(output).not.toContain("mystery"); + expect(output).not.toContain("9999"); + expect(hostile.gaps).toEqual( expect.arrayContaining(["status", "started_at", "updated_at", "freshness_at"]) ); - expect(response.stdout).not.toContain("mystery"); - expect(response.stdout).not.toContain("9999"); } }); - test("every unavailable normalized field has one stable named gap", async () => { + test("unsupported exact source reports stable incomplete coverage without records", async () => { const http = fakeHttp( exactResult( task(1, { @@ -422,64 +713,58 @@ describe("truthful state and completeness", () => { now: fixedNow }); const envelope = JSON.parse(response.stdout); - const agent = envelope.agents[0]; - expect(agent?.gaps).toEqual([ - "branch", - "freshness_at", - "goal.id", - "goal.status", - "goal.title", - "last_tool_call.at", - "last_tool_call.name", - "last_tool_call.summary", - "profile.alias", - "started_at", - "task.short_id", - "task.title", - "updated_at", - "worktree" - ]); + expect(http.calls).toHaveLength(0); + expect(envelope.agents).toEqual([]); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.sources[0].coverage).toEqual({ + complete: false, + provider_records: null, + projected_records: 0, + dropped_records: null + }); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); }); - test("does not project unassigned or merely assigned Todos tasks as agents", async () => { + test("does not project any Todos task without a safe authoritative surface", async () => { const cases = [ - { value: task(1, { agent_id: undefined }), exitCode: 5 }, + { value: task(1, { agent_id: undefined }) }, { - value: task(2, { agent_id: undefined, assigned_to: "account001" }), - exitCode: 5 + value: task(2, { agent_id: undefined, assigned_to: "account001" }) }, { - value: task(3, { session_id: uuid(103), agent_id: undefined }), - exitCode: 0 + value: task(3, { session_id: uuid(103), agent_id: undefined }) }, { value: task(4, { agent_id: undefined, locked_by: "lease-owner", lock_expires_at: "2026-07-23T12:10:00.000Z" - }), - exitCode: 0 + }) }, { value: task(5, { agent_id: undefined, locked_by: "expired-owner", lock_expires_at: "2026-07-23T11:59:00.000Z" - }), - exitCode: 5 + }) } ]; for (const item of cases) { + const http = fakeHttp(exactResult(item.value)); const response = await runAgentsCli( ["show", `todos:${String(item.value.id)}`, "--json"], { env: TODOS_ENV, - httpRunner: fakeHttp(exactResult(item.value)).runner, + httpRunner: http.runner, now: fixedNow } ); - expect(response.exitCode).toBe(item.exitCode); + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(JSON.parse(response.stdout).agents).toEqual([]); } }); @@ -553,7 +838,7 @@ describe("truthful state and completeness", () => { expect(http.calls).toHaveLength(0); }); - test("source freshness is the actual completion observation, not request start", async () => { + test("unobserved source freshness remains null instead of using request start", async () => { const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { env: TODOS_ENV, httpRunner: fakeHttp(exactResult(task(1))).runner, @@ -563,12 +848,13 @@ describe("truthful state and completeness", () => { const source = envelope.sources[0]; expect(envelope.generated_at).toBe(REQUEST_AT.toISOString()); - expect(source?.freshness_at).toBe(OBSERVED_AT.toISOString()); + expect(source?.freshness_at).toBeNull(); + expect(source?.coverage.complete).toBe(false); }); }); describe("exact lookup and CLI semantics", () => { - test("exact show queries only the selected provider with one targeted GET", async () => { + test("exact show queries only the selected provider and performs no unsafe operation", async () => { const target = task(201); const http = fakeHttp((request) => { expect(new URL(request.url).pathname).toBe(`/v1/tasks/${uuid(201)}`); @@ -582,11 +868,12 @@ describe("exact lookup and CLI semantics", () => { }); const envelope = JSON.parse(response.stdout); - expect(response.exitCode).toBe(0); - expect(http.calls).toHaveLength(1); + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); expect(envelope.sources).toHaveLength(1); expect(envelope.sources[0].provider).toBe("todos"); - expect(envelope.agents[0].id).toBe(`todos:${uuid(201)}`); + expect(envelope.sources[0].status).toBe("unavailable"); + expect(envelope.agents).toEqual([]); }); test("selected-source unavailability is incomplete, never false not-found", async () => { @@ -605,17 +892,19 @@ describe("exact lookup and CLI semantics", () => { expect(http.calls).toHaveLength(0); }); - test("only an exact complete 404 becomes agent-not-found", async () => { + test("arbitrary HTTP 404 cannot become authoritative agent-not-found", async () => { + const http = fakeHttp({ status: 404, body: '{"error":"not found"}' }); const response = await runAgentsCli(["show", `todos:${uuid(1)}`, "--json"], { env: TODOS_ENV, - httpRunner: fakeHttp({ status: 404, body: '{"error":"not found"}' }).runner, + httpRunner: http.runner, now: fixedNow }); const envelope = JSON.parse(response.stdout); - expect(response.exitCode).toBe(4); - expect(envelope.error.code).toBe("agent-not-found"); - expect(envelope.sources[0].coverage.complete).toBe(true); + expect(response.exitCode).toBe(5); + expect(http.calls).toHaveLength(0); + expect(envelope.error.code).toBe("agent-lookup-incomplete"); + expect(envelope.sources[0].coverage.complete).toBe(false); }); test("an exact task without agent provenance is incomplete, not absent", async () => { @@ -629,7 +918,9 @@ describe("exact lookup and CLI semantics", () => { expect(response.exitCode).toBe(5); expect(envelope.error.code).toBe("agent-lookup-incomplete"); - expect(envelope.sources[0].error.code).toBe("agent-provenance-missing"); + expect(envelope.sources[0].error.code).toBe( + "side-effect-free-surface-unavailable" + ); }); test("show rejects --limit and malformed or secret-like IDs before provider execution", async () => { @@ -657,7 +948,7 @@ describe("exact lookup and CLI semantics", () => { }); const envelope = JSON.parse(json.stdout); - expect(json.exitCode).toBe(0); + expect(json.exitCode).toBe(5); expect(envelope.schema_version).toBe(AGENTS_SCHEMA_VERSION); expect(Object.keys(envelope.sources[0]).sort()).toEqual([ "coverage", @@ -672,23 +963,7 @@ describe("exact lookup and CLI semantics", () => { "projected_records", "provider_records" ]); - expect(Object.keys(envelope.agents[0]).sort()).toEqual([ - "active", - "branch", - "freshness_at", - "gaps", - "goal", - "id", - "last_tool_call", - "profile", - "provider", - "run_id", - "started_at", - "status", - "task", - "updated_at", - "worktree" - ]); + expect(envelope.agents).toEqual([]); expect(formatAgentsTable(envelope)).toContain("WARNING incomplete sources:"); });