From 22dbddb77f9cd82f78b149291fd5a02bea8c5cd6 Mon Sep 17 00:00:00 2001 From: Dennis Verstappen <26124108+ape-rture@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:56:17 +0200 Subject: [PATCH] codex: add console session propagation --- README.md | 22 ++-- src/commands/index.ts | 88 ++++++++++++--- src/index.ts | 13 ++- src/lib/console-session.ts | 190 +++++++++++++++++++++++++++++++ src/lib/console-state.ts | 107 ++++++++++-------- src/lib/http.ts | 2 + src/lib/runtime.ts | 14 ++- src/test/cli.test.ts | 224 ++++++++++++++++++++++++++++++++++++- 8 files changed, 591 insertions(+), 69 deletions(-) create mode 100644 src/lib/console-session.ts diff --git a/README.md b/README.md index 95680e1..d9d4638 100644 --- a/README.md +++ b/README.md @@ -91,22 +91,30 @@ indexing-co agent doctor --session Resolution order for the session id: -1. `--session` -2. `INDEXING_CO_SESSION_ID` -3. `~/.indexing-co/session-id` +1. `--console-session` or legacy `--session` +2. `INDEXING_CO_CONSOLE_SESSION_ID` or legacy `INDEXING_CO_SESSION_ID` +3. Active session file written by `indexing-co agent watch` for the current project directory +4. Legacy `~/.indexing-co/session-id` Console URL resolution order: 1. `--console-url` 2. `INDEXING_CO_CONSOLE_URL` -3. `https://console.indexing.co` +3. Active session file written by `indexing-co agent watch` for the current project directory +4. `https://console.indexing.co` + +`agent watch` maintains Console presence and refreshes an expiring scoped session file under +`~/.indexing-co/console-sessions/`. Later mutating commands run from the same project directory +automatically reuse that session for Console activity reporting and attach `X-Session-Id` to API +requests, giving the API a canonical hook for server-side rail events. If the API mutation succeeds +but Console activity sync fails, the CLI prints a warning while still returning the mutation result. For staging or local development, pass an explicit override: ```bash -indexing-co agent watch --session --console-url https://staging.console.indexing.co -indexing-co agent doctor --session --console-url https://staging.console.indexing.co --json -INDEXING_CO_CONSOLE_URL=http://localhost:5173 indexing-co agent watch --session +indexing-co agent watch --console-session --console-url https://staging.console.indexing.co +indexing-co agent doctor --console-session --console-url https://staging.console.indexing.co --json +INDEXING_CO_CONSOLE_URL=http://localhost:5173 indexing-co agent watch --console-session ``` Library usage: diff --git a/src/commands/index.ts b/src/commands/index.ts index e733f79..14438c7 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); import { ensureConfigDirectory, promptForApiKey, removeCredentialsFile, writeCredentialsFile } from "../lib/auth"; +import { readActiveConsoleSession } from "../lib/console-session"; import { DEFAULT_BASE_URL, DEFAULT_CONSOLE_URL } from "../lib/constants"; import { getAgentPairingHealth, @@ -10,6 +11,7 @@ import { requestAccountKeyHandoff, resolveAgentSource, resolveConsoleSessionId, + resolveOptionalActivitySessionId, subscribeConsoleState, type AgentPairingHealth, type AgentActivityEventInput, @@ -148,15 +150,28 @@ async function recordAgentActivity( context: CommandContext, event: AgentActivityEventInput, ): Promise { - await reportAgentActivity({ + const explicitSessionId = context.options.consoleSession || context.options.session + ? String(context.options.consoleSession || context.options.session) + : undefined; + const resolvedSessionId = resolveOptionalActivitySessionId(explicitSessionId, context.env, context.cwd); + const reported = await reportAgentActivity({ ...event, - sessionId: context.options.session ? String(context.options.session) : undefined, + sessionId: explicitSessionId, consoleUrl: context.options.consoleUrl ? String(context.options.consoleUrl) : context.env.INDEXING_CO_CONSOLE_URL, source: context.options.source ? String(context.options.source) : undefined, apiKey: context.config.apiKey, + cwd: context.cwd, env: context.env, fetchImpl: context.fetchImpl, }); + if (resolvedSessionId && !reported) { + context.stderr.write("Warning: Console activity sync failed; the API mutation already succeeded.\n"); + } +} + +function resolveConsoleUrl(context: CommandContext): string | undefined { + const activeSession = readActiveConsoleSession({ cwd: context.cwd, env: context.env }); + return (context.options.consoleUrl as string | undefined) || context.env.INDEXING_CO_CONSOLE_URL || activeSession?.consoleUrl; } async function resolvePipelineTable(context: CommandContext, pipelineName: string): Promise { @@ -506,6 +521,18 @@ export function createRootCommand(): CommandDefinition { } const response = await context.http.post(`/pipelines/${encodeURIComponent(context.args[0])}/backfill`, body); + await recordAgentActivity(context, { + type: "update_pipeline", + target: { id: context.args[0], name: context.args[0], type: "pipeline" }, + metadata: compactObject({ + action: "backfill", + network: body.network, + value: body.value, + beatStart: body.beatStart, + beatEnd: body.beatEnd, + beatCount: Array.isArray(body.beats) ? body.beats.length : undefined, + }), + }); return renderRecord(`Backfill started for ${context.args[0]}`, response.data as Record); }, }, @@ -1101,13 +1128,18 @@ order by ordinal_position summary: "Request this console account's API key via in-console approval.", requiresAuth: false, options: [ + { name: "console-session", description: "Explicit console session id.", type: "string" }, { name: "session", description: "Explicit console session id.", type: "string" }, { name: "console-url", description: "Override the console base URL.", type: "string" }, { name: "source", description: "Agent name shown in the approval prompt.", type: "string" }, ], execute: async (context) => { - const sessionId = resolveConsoleSessionId(context.options.session as string | undefined, context.env); - const consoleUrl = (context.options.consoleUrl as string | undefined) || context.env.INDEXING_CO_CONSOLE_URL; + const sessionId = resolveConsoleSessionId( + (context.options.consoleSession || context.options.session) as string | undefined, + context.env, + context.cwd, + ); + const consoleUrl = resolveConsoleUrl(context); const source = resolveAgentSource(context.options.source as string | undefined, context.env); context.stderr.write( @@ -1191,6 +1223,7 @@ order by ordinal_position summary: "Subscribe to the console state stream.", requiresAuth: false, options: [ + { name: "console-session", description: "Explicit console session id.", type: "string" }, { name: "session", description: "Explicit console session id.", type: "string" }, { name: "console-url", description: "Override the console base URL.", type: "string" }, { name: "source", description: "Agent source shown in the console presence indicator.", type: "string" }, @@ -1198,8 +1231,12 @@ order by ordinal_position { name: "once", description: "Print the next event and exit.", type: "boolean" }, ], execute: async (context) => { - const sessionId = resolveConsoleSessionId(context.options.session as string | undefined, context.env); - const consoleUrl = (context.options.consoleUrl as string | undefined) || context.env.INDEXING_CO_CONSOLE_URL; + const sessionId = resolveConsoleSessionId( + (context.options.consoleSession || context.options.session) as string | undefined, + context.env, + context.cwd, + ); + const consoleUrl = resolveConsoleUrl(context); const source = resolveAgentSource(context.options.source as string | undefined, context.env); const verbose = Boolean(context.options.verbose); const once = Boolean(context.options.once); @@ -1215,6 +1252,9 @@ order by ordinal_position consoleUrl, source, apiKey: context.config.apiKey, + cwd: context.cwd, + env: context.env, + cliVersion: context.config.packageVersion, fetchImpl: context.fetchImpl, onEvent: (event) => { if (context.format === "json") { @@ -1254,12 +1294,17 @@ order by ordinal_position summary: "Fetch the current console state snapshot.", requiresAuth: false, options: [ + { name: "console-session", description: "Explicit console session id.", type: "string" }, { name: "session", description: "Explicit console session id.", type: "string" }, { name: "console-url", description: "Override the console base URL.", type: "string" }, ], execute: async (context) => { - const sessionId = resolveConsoleSessionId(context.options.session as string | undefined, context.env); - const consoleUrl = (context.options.consoleUrl as string | undefined) || context.env.INDEXING_CO_CONSOLE_URL; + const sessionId = resolveConsoleSessionId( + (context.options.consoleSession || context.options.session) as string | undefined, + context.env, + context.cwd, + ); + const consoleUrl = resolveConsoleUrl(context); const snapshot = await getCurrentUserState({ sessionId, consoleUrl, fetchImpl: context.fetchImpl }); return { data: snapshot, @@ -1274,12 +1319,17 @@ order by ordinal_position summary: "Check whether Console pairing is healthy.", requiresAuth: false, options: [ + { name: "console-session", description: "Explicit console session id.", type: "string" }, { name: "session", description: "Explicit console session id.", type: "string" }, { name: "console-url", description: "Override the console base URL.", type: "string" }, ], execute: async (context) => { - const sessionId = resolveConsoleSessionId(context.options.session as string | undefined, context.env); - const consoleUrl = (context.options.consoleUrl as string | undefined) || context.env.INDEXING_CO_CONSOLE_URL; + const sessionId = resolveConsoleSessionId( + (context.options.consoleSession || context.options.session) as string | undefined, + context.env, + context.cwd, + ); + const consoleUrl = resolveConsoleUrl(context); const health = await getAgentPairingHealth({ sessionId, consoleUrl, fetchImpl: context.fetchImpl }); return { @@ -1311,6 +1361,7 @@ order by ordinal_position { name: "target-kind", description: "Target kind (pipeline | filter | transformation).", type: "string" }, { name: "target-id", description: "Target identifier within the chosen kind.", type: "string" }, { name: "field", description: "Specific field on the target.", type: "string" }, + { name: "console-session", description: "Explicit console session id.", type: "string" }, { name: "note", description: "Short human note describing what changed (shown in the BYO Agent feed).", type: "string" }, { name: "ttl-ms", description: "How long the hint should remain visible (ms).", type: "number" }, { name: "agent", description: "Agent name (e.g. claude-code, codex-cli).", type: "string" }, @@ -1318,10 +1369,16 @@ order by ordinal_position ], requiresAuth: false, execute: async (context: CommandContext) => { + const sessionId = resolveOptionalActivitySessionId( + (context.options.consoleSession || context.options.session) as string | undefined, + context.env, + context.cwd, + ); + const consoleUrl = resolveConsoleUrl(context) || DEFAULT_CONSOLE_URL; const url = (context.options.url as string | undefined) || - process.env.INDEXING_CO_HINTS_URL || - `${DEFAULT_CONSOLE_URL}/api/hints/emit`; + context.env.INDEXING_CO_HINTS_URL || + `${consoleUrl.replace(/\/$/, "")}/api/hints/emit`; const body: Record = { type: context.args[0], ts: new Date().toISOString(), @@ -1343,9 +1400,12 @@ order by ordinal_position const note = context.options.note as string | undefined; if (note) body.note = note; - const response = await fetch(url, { + const response = await context.fetchImpl(url, { method: "POST", - headers: { "content-type": "application/json" }, + headers: compactObject({ + "content-type": "application/json", + "X-Session-Id": sessionId, + }) as Record, body: JSON.stringify(body), }); const text = await response.text(); diff --git a/src/index.ts b/src/index.ts index 47e6a07..2c5f27b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,19 @@ export { getAgentPairingHealth, getCurrentUserState, - readStoredSessionId, reportAgentActivity, resolveConsoleSessionId, resolveOptionalActivitySessionId, subscribeConsoleState, } from "./lib/console-state"; +export { + getActiveConsoleSessionPath, + getConsoleSessionScope, + readActiveConsoleSession, + readStoredSessionId, + resolveOptionalConsoleSessionContext, + writeActiveConsoleSession, +} from "./lib/console-session"; export type { AgentActivityEventInput, AgentActivityReportOptions, @@ -18,3 +25,7 @@ export type { ConsoleStateSubscription, ConsoleStateSubscriptionOptions, } from "./lib/console-state"; +export type { + ActiveConsoleSession, + ConsoleSessionContext, +} from "./lib/console-session"; diff --git a/src/lib/console-session.ts b/src/lib/console-session.ts new file mode 100644 index 0000000..4ad063c --- /dev/null +++ b/src/lib/console-session.ts @@ -0,0 +1,190 @@ +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +import { DEFAULT_CONSOLE_URL, getConfigDirectory, getSessionIdPath } from "./constants"; + +const SAFE_AGENT_SOURCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/; +const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/; +const ACTIVITY_SESSION_ID_PATTERN = /^[a-f0-9-]{36}$/i; +const SAFE_SCOPE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; +const ACTIVE_SESSION_TTL_MS = 30_000; +const DEFAULT_AGENT_SOURCE = "indexing-co-cli"; + +export interface ActiveConsoleSession { + sessionId: string; + consoleUrl: string; + source: string; + scope: string; + cwd: string; + lastHeartbeatAt: string; + expiresAt: string; + watcherPid: number; + cliVersion: string; +} + +export interface ConsoleSessionContext { + sessionId?: string; + consoleUrl: string; + source: string; + activeSession?: ActiveConsoleSession; +} + +export function normalizeConsoleUrl(consoleUrl?: string): string { + const url = (consoleUrl || process.env.INDEXING_CO_CONSOLE_URL || DEFAULT_CONSOLE_URL).trim(); + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +export function normalizeAgentSource(source?: string): string { + const value = (source || process.env.INDEXING_CO_AGENT_SOURCE || DEFAULT_AGENT_SOURCE).trim(); + return SAFE_AGENT_SOURCE_PATTERN.test(value) ? value : DEFAULT_AGENT_SOURCE; +} + +export function assertSafeSessionId(sessionId: string): string { + if (!SAFE_SESSION_ID_PATTERN.test(sessionId)) { + throw new Error("Invalid console session id."); + } + return sessionId; +} + +export function readStoredSessionId(env: Record = process.env): string | undefined { + const sessionIdPath = getSessionIdPath(env); + try { + if (!fs.existsSync(sessionIdPath)) { + return undefined; + } + + const value = String(fs.readFileSync(sessionIdPath, "utf8")).trim(); + return value || undefined; + } catch { + return undefined; + } +} + +export function getConsoleSessionScope( + cwd: string = process.cwd(), + env: Record = process.env, +): string { + const explicitScope = env.INDEXING_CO_CONSOLE_SCOPE?.trim(); + if (explicitScope && SAFE_SCOPE_PATTERN.test(explicitScope)) { + return explicitScope; + } + + let resolvedCwd = cwd; + try { + resolvedCwd = fs.realpathSync(cwd); + } catch { + resolvedCwd = path.resolve(cwd); + } + + return crypto.createHash("sha256").update(resolvedCwd).digest("hex").slice(0, 16); +} + +export function getConsoleSessionsDirectory(env: Record = process.env): string { + return path.join(getConfigDirectory(env), "console-sessions"); +} + +export function getActiveConsoleSessionPath(options: { + cwd?: string; + env?: Record; + scope?: string; +} = {}): string { + const env = options.env || process.env; + const scope = options.scope || getConsoleSessionScope(options.cwd, env); + return path.join(getConsoleSessionsDirectory(env), `${scope}.json`); +} + +function parseActiveConsoleSession(contents: string): ActiveConsoleSession | undefined { + try { + const parsed = JSON.parse(contents) as Partial; + if (!parsed.sessionId || !parsed.consoleUrl || !parsed.expiresAt) { + return undefined; + } + return parsed as ActiveConsoleSession; + } catch { + return undefined; + } +} + +export function readActiveConsoleSession(options: { + cwd?: string; + env?: Record; + now?: Date; +} = {}): ActiveConsoleSession | undefined { + const sessionPath = getActiveConsoleSessionPath(options); + try { + if (!fs.existsSync(sessionPath)) { + return undefined; + } + + const parsed = parseActiveConsoleSession(fs.readFileSync(sessionPath, "utf8")); + if (!parsed) { + return undefined; + } + + const nowMs = (options.now || new Date()).getTime(); + if (Number.isNaN(Date.parse(parsed.expiresAt)) || Date.parse(parsed.expiresAt) <= nowMs) { + return undefined; + } + + return parsed; + } catch { + return undefined; + } +} + +export function writeActiveConsoleSession(options: { + sessionId: string; + consoleUrl?: string; + source?: string; + cwd?: string; + env?: Record; + cliVersion: string; + now?: Date; +}): ActiveConsoleSession { + const env = options.env || process.env; + const cwd = options.cwd || process.cwd(); + const now = options.now || new Date(); + const scope = getConsoleSessionScope(cwd, env); + const session: ActiveConsoleSession = { + sessionId: options.sessionId, + consoleUrl: normalizeConsoleUrl(options.consoleUrl || env.INDEXING_CO_CONSOLE_URL), + source: normalizeAgentSource(options.source || env.INDEXING_CO_AGENT_SOURCE), + scope, + cwd: path.resolve(cwd), + lastHeartbeatAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ACTIVE_SESSION_TTL_MS).toISOString(), + watcherPid: process.pid, + cliVersion: options.cliVersion, + }; + + const sessionPath = getActiveConsoleSessionPath({ cwd, env, scope }); + fs.mkdirSync(path.dirname(sessionPath), { recursive: true }); + fs.writeFileSync(sessionPath, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); + return session; +} + +export function resolveOptionalConsoleSessionContext(options: { + explicitSessionId?: string; + explicitConsoleUrl?: string; + explicitSource?: string; + cwd?: string; + env?: Record; +} = {}): ConsoleSessionContext { + const env = options.env || process.env; + const activeSession = readActiveConsoleSession({ cwd: options.cwd, env }); + const sessionId = ( + options.explicitSessionId || + env.INDEXING_CO_CONSOLE_SESSION_ID || + env.INDEXING_CO_SESSION_ID || + activeSession?.sessionId || + readStoredSessionId(env) + )?.trim(); + + return { + sessionId: sessionId && ACTIVITY_SESSION_ID_PATTERN.test(sessionId) ? sessionId : undefined, + consoleUrl: normalizeConsoleUrl(options.explicitConsoleUrl || env.INDEXING_CO_CONSOLE_URL || activeSession?.consoleUrl), + source: normalizeAgentSource(options.explicitSource || env.INDEXING_CO_AGENT_SOURCE || activeSession?.source), + activeSession, + }; +} diff --git a/src/lib/console-state.ts b/src/lib/console-state.ts index 95c6204..4f580b0 100644 --- a/src/lib/console-state.ts +++ b/src/lib/console-state.ts @@ -1,7 +1,15 @@ -const fs = require("node:fs"); const crypto = require("node:crypto"); -import { DEFAULT_CONSOLE_URL, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_UPDATE_TIMEOUT_MS, getSessionIdPath } from "./constants"; +import { DEFAULT_CONSOLE_URL, DEFAULT_HTTP_TIMEOUT_MS, DEFAULT_UPDATE_TIMEOUT_MS } from "./constants"; +import { + assertSafeSessionId, + normalizeAgentSource, + normalizeConsoleUrl, + readActiveConsoleSession, + readStoredSessionId, + resolveOptionalConsoleSessionContext, + writeActiveConsoleSession, +} from "./console-session"; import { CliError, EXIT_CODES } from "./errors"; import { computeKeyFingerprint } from "./key-fingerprint"; @@ -28,6 +36,9 @@ export interface ConsoleStateSubscriptionOptions { // sent with presence heartbeats. The raw key itself never leaves this // process — see src/lib/key-fingerprint.ts. apiKey?: string; + cwd?: string; + env?: Record; + cliVersion?: string; onEvent: (event: ConsoleStateEvent) => void; onTransportError?: (error: Error, context: { attempt: number; reconnectInMs: number }) => void; fetchImpl?: typeof fetch; @@ -73,6 +84,7 @@ export interface AgentActivityReportOptions extends AgentActivityEventInput { // attached to the event. Raw key never serialized. apiKey?: string; env?: Record; + cwd?: string; fetchImpl?: typeof fetch; timeoutMs?: number; } @@ -123,34 +135,29 @@ type SharedConnection = { subscribers: Map; url: string; backoffMs: (attempt: number) => number; + onPresenceHeartbeat?: () => void; }; let nextSubscriberId = 1; const sharedConnections = new Map(); const PRESENCE_HEARTBEAT_MS = 10_000; -const DEFAULT_AGENT_SOURCE = "indexing-co-cli"; -const SAFE_AGENT_SOURCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/; const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,127}$/; -const ACTIVITY_SESSION_ID_PATTERN = /^[a-f0-9-]{36}$/i; - -function normalizeConsoleUrl(consoleUrl?: string): string { - const url = (consoleUrl || process.env.INDEXING_CO_CONSOLE_URL || DEFAULT_CONSOLE_URL).trim(); - return url.endsWith("/") ? url.slice(0, -1) : url; -} -function normalizeAgentSource(source?: string): string { - const value = (source || process.env.INDEXING_CO_AGENT_SOURCE || DEFAULT_AGENT_SOURCE).trim(); - return SAFE_AGENT_SOURCE_PATTERN.test(value) ? value : DEFAULT_AGENT_SOURCE; -} - -function assertSafeSessionId(sessionId: string): string { +function assertCliSafeSessionId(sessionId: string): string { if (!SAFE_SESSION_ID_PATTERN.test(sessionId)) { throw new CliError( "Invalid console session id. Pass the session id shown in the console BYO Agent panel.", EXIT_CODES.USAGE, ); } - return sessionId; + try { + return assertSafeSessionId(sessionId); + } catch { + throw new CliError( + "Invalid console session id. Pass the session id shown in the console BYO Agent panel.", + EXIT_CODES.USAGE, + ); + } } function buildConsoleUrl(consoleUrl: string, pathName: string): string { @@ -176,6 +183,7 @@ async function sendPresenceHeartbeat(connection: SharedConnection, signal?: Abor }), signal, }); + connection.onPresenceHeartbeat?.(); } catch { // Best-effort UI presence: keep streaming even if the heartbeat fails. } @@ -395,46 +403,36 @@ function getConnectionKey(consoleUrl: string, sessionId: string, source: string) return `${normalizeConsoleUrl(consoleUrl)}::${sessionId}::${normalizeAgentSource(source)}`; } -export function readStoredSessionId(env: Record = process.env): string | undefined { - const sessionIdPath = getSessionIdPath(env); - try { - if (!fs.existsSync(sessionIdPath)) { - return undefined; - } - - const value = String(fs.readFileSync(sessionIdPath, "utf8")).trim(); - return value || undefined; - } catch { - return undefined; - } -} - export function resolveConsoleSessionId( explicitSessionId: string | undefined, env: Record = process.env, + cwd?: string, ): string { - const envSessionId = env.INDEXING_CO_SESSION_ID?.trim(); - const sessionId = explicitSessionId || envSessionId || readStoredSessionId(env); + const activeSession = readActiveConsoleSession({ cwd, env }); + const sessionId = ( + explicitSessionId || + env.INDEXING_CO_CONSOLE_SESSION_ID || + env.INDEXING_CO_SESSION_ID || + activeSession?.sessionId || + readStoredSessionId(env) + )?.trim(); if (!sessionId) { throw new CliError( - "No session id available. Pass --session explicitly (copy from the console's BYO Agent panel) " + - "or set the INDEXING_CO_SESSION_ID env var, or place the id at ~/.indexing-co/session-id.", + "No session id available. Pass --console-session explicitly (copy from the console's BYO Agent panel), " + + "set INDEXING_CO_CONSOLE_SESSION_ID, keep `indexing-co agent watch` running in this project, " + + "or place the id at ~/.indexing-co/session-id.", EXIT_CODES.USAGE, ); } - return assertSafeSessionId(sessionId); + return assertCliSafeSessionId(sessionId); } export function resolveOptionalActivitySessionId( explicitSessionId: string | undefined, env: Record = process.env, + cwd?: string, ): string | undefined { - const envSessionId = env.INDEXING_CO_SESSION_ID?.trim(); - const sessionId = explicitSessionId || envSessionId || readStoredSessionId(env); - if (!sessionId) { - return undefined; - } - return ACTIVITY_SESSION_ID_PATTERN.test(sessionId) ? sessionId : undefined; + return resolveOptionalConsoleSessionContext({ explicitSessionId, env, cwd }).sessionId; } export function resolveAgentSource( @@ -445,7 +443,14 @@ export function resolveAgentSource( } export async function reportAgentActivity(options: AgentActivityReportOptions): Promise { - const sessionId = resolveOptionalActivitySessionId(options.sessionId, options.env); + const sessionContext = resolveOptionalConsoleSessionContext({ + explicitSessionId: options.sessionId, + explicitConsoleUrl: options.consoleUrl, + explicitSource: options.source, + cwd: options.cwd, + env: options.env, + }); + const sessionId = sessionContext.sessionId; if (!sessionId) { return false; } @@ -453,7 +458,7 @@ export async function reportAgentActivity(options: AgentActivityReportOptions): const fetchImpl = options.fetchImpl || fetch; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeoutMs || DEFAULT_UPDATE_TIMEOUT_MS); - const source = normalizeAgentSource(options.source || options.env?.INDEXING_CO_AGENT_SOURCE); + const source = sessionContext.source; const data = { id: crypto.randomUUID(), @@ -471,7 +476,7 @@ export async function reportAgentActivity(options: AgentActivityReportOptions): }; try { - const response = await fetchImpl(buildConsoleUrl(options.consoleUrl || DEFAULT_CONSOLE_URL, "/api/session/event"), { + const response = await fetchImpl(buildConsoleUrl(sessionContext.consoleUrl || DEFAULT_CONSOLE_URL, "/api/session/event"), { method: "POST", headers: { Accept: "application/json", @@ -671,6 +676,18 @@ export function subscribeConsoleState(options: ConsoleStateSubscriptionOptions): subscribers: new Map(), url: consoleUrl, backoffMs: options.backoffMs || defaultBackoffMs, + onPresenceHeartbeat: options.cliVersion + ? () => { + writeActiveConsoleSession({ + sessionId: options.sessionId, + consoleUrl, + source, + cwd: options.cwd, + env: options.env, + cliVersion: options.cliVersion as string, + }); + } + : undefined, }; sharedConnections.set(key, connection); } diff --git a/src/lib/http.ts b/src/lib/http.ts index 9f95c5f..30b8f61 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -20,6 +20,7 @@ export interface ApiResponse { export interface HttpClientOptions { apiKey?: string; baseUrl: string; + consoleSessionId?: string; userAgent: string; fetchImpl?: typeof fetch; } @@ -89,6 +90,7 @@ export function createHttpClient(options: HttpClientOptions) { Accept: "application/json, text/plain;q=0.9, */*;q=0.8", "User-Agent": options.userAgent, "X-API-KEY": options.apiKey, + "X-Session-Id": options.consoleSessionId, ...(spec.headers || {}), }) as Record; diff --git a/src/lib/runtime.ts b/src/lib/runtime.ts index b97bb41..bfbc8d9 100644 --- a/src/lib/runtime.ts +++ b/src/lib/runtime.ts @@ -2,6 +2,7 @@ const path = require("node:path"); import { resolveApiKey } from "./auth"; import { DEFAULT_BASE_URL, getCredentialsPath, getStatePath, readPackageMetadata } from "./constants"; +import { resolveOptionalConsoleSessionContext } from "./console-session"; import { CliError, EXIT_CODES, toCliError } from "./errors"; import { createHttpClient } from "./http"; import { renderHumanResult, renderJsonResult, type CommandResult } from "./output"; @@ -74,8 +75,9 @@ const GLOBAL_OPTIONS: OptionDefinition[] = [ { name: "json", short: "j", description: "Emit structured JSON instead of human-readable output.", type: "boolean" }, { name: "api-key", description: "Override the API key for this invocation.", type: "string" }, { name: "base-url", description: "Override the API base URL.", type: "string" }, + { name: "console-session", description: "Console session id for activity reporting.", type: "string" }, + { name: "console-url", description: "Console URL for activity reporting.", type: "string" }, { name: "session", description: "Console session id for activity reporting.", type: "string", hidden: true }, - { name: "console-url", description: "Console URL for activity reporting.", type: "string", hidden: true }, { name: "source", description: "Agent source for activity reporting.", type: "string", hidden: true }, { name: "no-update-check", description: "Skip the npm version check banner.", type: "boolean" }, { name: "version", short: "v", description: "Show the CLI version.", type: "boolean" }, @@ -405,6 +407,15 @@ function buildContext( const credentialsPath = getCredentialsPath(env); const statePath = getStatePath(env); const baseUrl = String(parsed.options.baseUrl || env.INDEXING_CO_BASE_URL || DEFAULT_BASE_URL); + const consoleSession = resolveOptionalConsoleSessionContext({ + explicitSessionId: parsed.options.consoleSession || parsed.options.session + ? String(parsed.options.consoleSession || parsed.options.session) + : undefined, + explicitConsoleUrl: parsed.options.consoleUrl ? String(parsed.options.consoleUrl) : undefined, + explicitSource: parsed.options.source ? String(parsed.options.source) : undefined, + cwd, + env, + }); const credential = resolveApiKey({ apiKeyFlag: parsed.options.apiKey ? String(parsed.options.apiKey) : undefined, env, @@ -435,6 +446,7 @@ function buildContext( http: createHttpClient({ apiKey: credential.apiKey, baseUrl, + consoleSessionId: consoleSession.sessionId, userAgent: `${packageMetadata.name}/${packageMetadata.version}`, fetchImpl: options.fetchImpl, }), diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 139a41e..93524bc 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -345,6 +345,228 @@ test("mutation commands skip activity reporting when no console session is confi assert.equal(activityRequests.length, 0); }); +test("agent watch writes an active session used by later mutation commands", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ico-home-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "ico-cwd-")); + const sessionId = "22222222-2222-4222-8222-222222222222"; + const watchStdout = createWriter(); + const watchStderr = createWriter(); + + const watchExitCode = await runCli(createRootCommand(), [ + "agent", + "watch", + "--console-session", + sessionId, + "--console-url", + "https://staging.console.indexing.co", + "--source", + "codex-cli", + "--once", + ], { + cwd, + stdout: watchStdout.stream, + stderr: watchStderr.stream, + env: { HOME: home }, + fetchImpl: async (input, init) => { + const url = String(input); + if (url.endsWith("/api/state/presence")) { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url.endsWith("/api/state/stream")) { + assert.equal((init?.headers as Record)["X-Session-Id"], sessionId); + return createSseResponse(["event: route_change\ndata: {\"route\":\"/pipelines\"}\n\n"], init?.signal as AbortSignal | undefined); + } + throw new Error(`Unexpected watch request: ${url}`); + }, + }); + + assert.equal(watchExitCode, 0); + const sessionDir = path.join(home, ".indexing-co", "console-sessions"); + const sessionFiles = fs.readdirSync(sessionDir); + assert.equal(sessionFiles.length, 1); + const activeSession = JSON.parse(fs.readFileSync(path.join(sessionDir, sessionFiles[0]), "utf8")); + assert.equal(activeSession.sessionId, sessionId); + assert.equal(activeSession.consoleUrl, "https://staging.console.indexing.co"); + assert.equal(activeSession.source, "codex-cli"); + + const stdout = createWriter(); + const stderr = createWriter(); + const requests: Array<{ url: string; sessionId?: string; body?: unknown }> = []; + + const mutationExitCode = await runCli(createRootCommand(), ["filter", "create", "demo_filter", "--values", "0xabc"], { + cwd, + stdout: stdout.stream, + stderr: stderr.stream, + env: { + HOME: home, + INDEXING_CO_API_KEY: "test-key", + }, + fetchImpl: async (input, init) => { + const url = String(input); + if (url === "https://app.indexing.co/dw/filters/demo_filter") { + requests.push({ url, sessionId: (init?.headers as Record)["X-Session-Id"] }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (url === "https://staging.console.indexing.co/api/session/event") { + requests.push({ + url, + sessionId: (init?.headers as Record)["X-Session-Id"], + body: JSON.parse(String(init?.body)), + }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + throw new Error(`Unexpected mutation request: ${url}`); + }, + }); + + assert.equal(mutationExitCode, 0); + assert.equal(requests.length, 2); + assert.equal(requests[0].sessionId, sessionId); + assert.equal(requests[1].sessionId, sessionId); + const activityData = (requests[1].body as Record).data as Record; + assert.equal(activityData.type, "create_filter"); + + const stateStdout = createWriter(); + const stateStderr = createWriter(); + let stateUrl = ""; + let stateSession = ""; + const stateExitCode = await runCli(createRootCommand(), ["agent", "state"], { + cwd, + stdout: stateStdout.stream, + stderr: stateStderr.stream, + env: { HOME: home }, + fetchImpl: async (input, init) => { + stateUrl = String(input); + stateSession = String((init?.headers as Record)["X-Session-Id"]); + return new Response(JSON.stringify({ route: "/pipelines" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + assert.equal(stateExitCode, 0); + assert.equal(stateUrl, "https://staging.console.indexing.co/api/state/current"); + assert.equal(stateSession, sessionId); + + const hintStdout = createWriter(); + const hintStderr = createWriter(); + let hintUrl = ""; + let hintSession = ""; + const hintExitCode = await runCli(createRootCommand(), [ + "hint", + "emit", + "pipeline-created", + "--target-kind", + "pipeline", + "--target-id", + "demo_pipeline", + ], { + cwd, + stdout: hintStdout.stream, + stderr: hintStderr.stream, + env: { HOME: home }, + fetchImpl: async (input, init) => { + hintUrl = String(input); + hintSession = String((init?.headers as Record)["X-Session-Id"]); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + assert.equal(hintExitCode, 0); + assert.equal(hintUrl, "https://staging.console.indexing.co/api/hints/emit"); + assert.equal(hintSession, sessionId); +}); + +test("mutation activity sync failure warns without failing the mutation", async () => { + const stdout = createWriter(); + const stderr = createWriter(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ico-home-")); + + const exitCode = await runCli(createRootCommand(), ["filter", "create", "demo_filter", "--values", "0xabc"], { + stdout: stdout.stream, + stderr: stderr.stream, + env: { + HOME: home, + INDEXING_CO_API_KEY: "test-key", + INDEXING_CO_CONSOLE_SESSION_ID: "33333333-3333-4333-8333-333333333333", + }, + fetchImpl: async (input) => { + const url = String(input); + if (url === "https://app.indexing.co/dw/filters/demo_filter") { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (url === "https://console.indexing.co/api/session/event") { + return new Response(JSON.stringify({ ok: false }), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + throw new Error(`Unexpected request: ${url}`); + }, + }); + + assert.equal(exitCode, 0); + assert.match(stderr.read(), /Console activity sync failed/); +}); + +test("hint emit sends the resolved console session header", async () => { + const stdout = createWriter(); + const stderr = createWriter(); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "ico-home-")); + const sessionId = "44444444-4444-4444-8444-444444444444"; + let observedSession = ""; + + const exitCode = await runCli(createRootCommand(), [ + "hint", + "emit", + "pipeline-created", + "--target-kind", + "pipeline", + "--target-id", + "demo_pipeline", + "--note", + "done", + ], { + stdout: stdout.stream, + stderr: stderr.stream, + env: { + HOME: home, + INDEXING_CO_CONSOLE_SESSION_ID: sessionId, + }, + fetchImpl: async (input, init) => { + assert.equal(String(input), "https://console.indexing.co/api/hints/emit"); + observedSession = String((init?.headers as Record)["X-Session-Id"]); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + assert.equal(exitCode, 0); + assert.equal(observedSession, sessionId); +}); + test("completion command prints a bash script", async () => { const stdout = createWriter(); const stderr = createWriter(); @@ -369,7 +591,7 @@ test("agent state errors clearly when no session id is available", async () => { }); assert.equal(exitCode, 2); - assert.match(stderr.read(), /No session id available\. Pass --session/); + assert.match(stderr.read(), /No session id available\. Pass --console-session/); }); test("agent watch prefers explicit session id over the stored file", async () => {