@gobing-ai/ts-ai-runner 0.3.21 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ import { type FileSystem } from '@gobing-ai/ts-runtime';
2
+ import type { AiRunner } from '../ai-runner';
3
+ import { type AgentName, getAgentShim } from './shims';
4
+ /**
5
+ * Authentication state for a coding agent — **tri-state by design**.
6
+ *
7
+ * `unknown` is first-class: several agents have no reliable auth probe
8
+ * (no auth verb, no checkable credential file). Collapsing `unknown` to
9
+ * `unauthenticated` is exactly the false-negative that used to make
10
+ * `antigravity-cli` (no auth verb) and inconclusive probes report
11
+ * `usable: false` despite being runnable.
12
+ *
13
+ * Auth detection is **off the execution critical path**: {@link isAuthenticated}
14
+ * never feeds run-readiness — it is operator information only. A genuinely
15
+ * unauthenticated agent fails at runtime with its own error (auth is the
16
+ * agent's concern).
17
+ */
18
+ export type AuthState = 'authenticated' | 'unauthenticated' | 'unknown';
19
+ /** Context injected into {@link isAuthenticated}. */
20
+ export interface AuthContext {
21
+ /** Runner used to execute auth probes. */
22
+ runner: AiRunner;
23
+ /** Environment map for env/file auth checks. Defaults to the process env. */
24
+ env?: Record<string, string | undefined>;
25
+ /** Filesystem for auth-file checks. Required — inject a fake in tests. */
26
+ fileSystem: FileSystem;
27
+ /** Auth probe timeout in milliseconds. */
28
+ timeout?: number;
29
+ }
30
+ /**
31
+ * Resolve a coding agent's authentication state.
32
+ *
33
+ * **Never throws.** Any probe failure, inconclusive output, or missing probe
34
+ * resolves to `unknown` rather than `unauthenticated` — see {@link AuthState}.
35
+ *
36
+ * Auth sources, in priority order per agent:
37
+ * - **gemini** — credential-like content in `~/.gemini/settings.json`.
38
+ * - **codex** — `codex login status` CLI output, falling back to an
39
+ * `~/.codex/auth{.json,}` credential file.
40
+ * - **pi / omp** — a non-empty `GOOGLE_API_KEY` or `ANTHROPIC_API_KEY` in env
41
+ * (an empty export is not a usable credential), else the CLI auth probe.
42
+ * - **others** — the shim's auth command; matched against {@link AUTH_PATTERNS}.
43
+ * - **no probe** (e.g. `antigravity-cli`) ⇒ `unknown`.
44
+ */
45
+ export declare function isAuthenticated(agent: AgentName, ctx: AuthContext): Promise<AuthState>;
46
+ export { getAgentShim };
47
+ //# sourceMappingURL=auth-shims.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-shims.d.ts","sourceRoot":"","sources":["../../src/agents/auth-shims.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,UAAU,EAA2B,MAAM,uBAAuB,CAAC;AACjF,OAAO,KAAK,EAAmC,QAAQ,EAAE,MAAM,cAAc,CAAC;AAC9E,OAAO,EAAE,KAAK,SAAS,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,SAAS,GAAG,eAAe,GAAG,iBAAiB,GAAG,SAAS,CAAC;AAExE,qDAAqD;AACrD,MAAM,WAAW,WAAW;IACxB,0CAA0C;IAC1C,MAAM,EAAE,QAAQ,CAAC;IACjB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,0EAA0E;IAC1E,UAAU,EAAE,UAAU,CAAC;IACvB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;CACpB;AA6CD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,CAAC,CAgB5F;AA8CD,OAAO,EAAE,YAAY,EAAE,CAAC"}
@@ -0,0 +1,120 @@
1
+ import { getProcessEnv, joinPath } from '@gobing-ai/ts-runtime';
2
+ import { getAgentShim } from './shims.js';
3
+ const DEFAULT_AUTH_TIMEOUT_MS = 5_000;
4
+ /**
5
+ * Per-agent auth-probe output patterns. A positive match ⇒ authenticated;
6
+ * a negative match ⇒ unauthenticated; neither (or no probe) ⇒ unknown.
7
+ */
8
+ const AUTH_PATTERNS = {
9
+ claude: {
10
+ positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
11
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
12
+ },
13
+ codex: {
14
+ positive: /logged[\s_-]*in|authenticated/i,
15
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
16
+ },
17
+ opencode: {
18
+ positive: /configured|available/i,
19
+ negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
20
+ },
21
+ openclaw: {
22
+ positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
23
+ negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
24
+ },
25
+ pi: {
26
+ positive: /\S/,
27
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
28
+ },
29
+ omp: {
30
+ positive: /\S/,
31
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
32
+ },
33
+ hermes: {
34
+ positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
35
+ negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
36
+ },
37
+ };
38
+ /** True when a value is a defined, non-blank string. */
39
+ function isNonEmpty(value) {
40
+ return value !== undefined && value.trim().length > 0;
41
+ }
42
+ /**
43
+ * Resolve a coding agent's authentication state.
44
+ *
45
+ * **Never throws.** Any probe failure, inconclusive output, or missing probe
46
+ * resolves to `unknown` rather than `unauthenticated` — see {@link AuthState}.
47
+ *
48
+ * Auth sources, in priority order per agent:
49
+ * - **gemini** — credential-like content in `~/.gemini/settings.json`.
50
+ * - **codex** — `codex login status` CLI output, falling back to an
51
+ * `~/.codex/auth{.json,}` credential file.
52
+ * - **pi / omp** — a non-empty `GOOGLE_API_KEY` or `ANTHROPIC_API_KEY` in env
53
+ * (an empty export is not a usable credential), else the CLI auth probe.
54
+ * - **others** — the shim's auth command; matched against {@link AUTH_PATTERNS}.
55
+ * - **no probe** (e.g. `antigravity-cli`) ⇒ `unknown`.
56
+ */
57
+ export async function isAuthenticated(agent, ctx) {
58
+ const env = ctx.env ?? getProcessEnv();
59
+ const fs = ctx.fileSystem;
60
+ const timeout = ctx.timeout ?? DEFAULT_AUTH_TIMEOUT_MS;
61
+ const home = env.HOME || env.USERPROFILE || '';
62
+ if (agent === 'gemini')
63
+ return geminiSettingsContainCredentials(fs, home);
64
+ if (agent === 'codex')
65
+ return checkCodexAuth(ctx.runner, fs, home, timeout);
66
+ // pi and omp read provider keys from the environment; require a non-empty
67
+ // value rather than mere presence (an empty export is not a usable credential).
68
+ if ((agent === 'pi' || agent === 'omp') && (isNonEmpty(env.GOOGLE_API_KEY) || isNonEmpty(env.ANTHROPIC_API_KEY))) {
69
+ return 'authenticated';
70
+ }
71
+ return probeAuthOutput(ctx.runner, agent, timeout);
72
+ }
73
+ async function checkCodexAuth(runner, fs, home, timeout) {
74
+ const probeStatus = await probeAuthOutput(runner, 'codex', timeout);
75
+ if (probeStatus !== 'unknown')
76
+ return probeStatus;
77
+ const hasFile = (await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth.json'))) ||
78
+ (await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth')));
79
+ return hasFile ? 'authenticated' : 'unknown';
80
+ }
81
+ async function geminiSettingsContainCredentials(fs, home) {
82
+ try {
83
+ const content = await fs.readFile(joinPath(home, '.gemini', 'settings.json'));
84
+ return /auth|token|key/i.test(content) ? 'authenticated' : 'unauthenticated';
85
+ }
86
+ catch {
87
+ // Missing or unreadable settings — auth state genuinely unknown.
88
+ return 'unknown';
89
+ }
90
+ }
91
+ async function probeAuthOutput(runner, agent, timeout) {
92
+ const options = { timeout };
93
+ const command = runner.runAuthCommand(agent, options);
94
+ const patterns = AUTH_PATTERNS[agent];
95
+ if (command === null || patterns === undefined)
96
+ return 'unknown';
97
+ try {
98
+ const result = await command;
99
+ if (result.exitCode !== 0)
100
+ return 'unauthenticated';
101
+ const output = `${result.stdout}\n${result.stderr}`;
102
+ if (patterns.negative.test(output))
103
+ return 'unauthenticated';
104
+ if (patterns.positive.test(output))
105
+ return 'authenticated';
106
+ return 'unknown';
107
+ }
108
+ catch {
109
+ return 'unknown';
110
+ }
111
+ }
112
+ /** True when the path exists and has a non-zero size. */
113
+ async function hasNonEmptyFile(fs, path) {
114
+ const stat = await fs.stat(path);
115
+ if (stat === null)
116
+ return false;
117
+ return stat.isFile() && stat.size > 0;
118
+ }
119
+ // Re-exported so callers that only need the auth facet don't import getAgentShim directly.
120
+ export { getAgentShim };
@@ -21,6 +21,8 @@ export interface AgentRunOptions {
21
21
  cwd?: string;
22
22
  /** Timeout in milliseconds. */
23
23
  timeout?: number;
24
+ /** AbortSignal forwarded to ProcessExecutor — aborts the agent subprocess when fired. */
25
+ signal?: AbortSignal;
24
26
  }
25
27
  /** Constructor options for AiRunner. */
26
28
  export interface AiRunnerOptions {
@@ -1 +1 @@
1
- {"version":3,"file":"ai-runner.d.ts","sourceRoot":"","sources":["../src/ai-runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ai-runner.d.ts","sourceRoot":"","sources":["../src/ai-runner.ts"],"names":[],"mappings":"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"}
package/dist/ai-runner.js CHANGED
@@ -71,6 +71,7 @@ export class AiRunner {
71
71
  forceBuffered,
72
72
  cwd: options.cwd ?? this.defaultCwd,
73
73
  timeout: options.timeout ?? this.defaultTimeout,
74
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
74
75
  });
75
76
  if (result.exitCode !== 0) {
76
77
  this.logger.error('invoke exited non-zero', {
@@ -1,6 +1,7 @@
1
1
  import { type Logger } from '@gobing-ai/ts-infra';
2
2
  import { type FileSystem } from '@gobing-ai/ts-runtime';
3
3
  import { AgentDetector } from './agent-detector';
4
+ import { type AuthState } from './agents/auth-shims';
4
5
  import { type AgentName } from './agents/shims';
5
6
  import { AiRunner } from './ai-runner';
6
7
  /** Health-check result for one coding agent. */
@@ -11,9 +12,18 @@ export interface DoctorResult {
11
12
  installed: boolean;
12
13
  /** Human-readable version when installed. */
13
14
  version: string | null;
14
- /** Whether the agent appears authenticated. */
15
- authenticated: boolean;
16
- /** Whether the agent is ready for direct use. */
15
+ /**
16
+ * Authentication state — **tri-state** (`authenticated | unauthenticated |
17
+ * unknown`). Informational only; never feeds {@link usable} or any
18
+ * run-readiness gate. A genuinely unauthenticated agent fails at runtime
19
+ * with its own error.
20
+ */
21
+ authenticated: AuthState;
22
+ /**
23
+ * Whether the agent is **runnable** — liveness only
24
+ * (`installed && version !== null`). Auth is NOT consulted: a logged-out
25
+ * agent is still `usable` (it will fail at runtime with its own auth error).
26
+ */
17
27
  usable: boolean;
18
28
  /** Capability tier: 1 = direct CLI, 2 = gateway/TUI constrained. */
19
29
  tier: 1 | 2;
@@ -41,7 +51,7 @@ export interface DoctorRunnerOptions {
41
51
  /** Filesystem for auth-file checks. Defaults to `createNodeFileSystem()`; inject to test without disk. */
42
52
  fileSystem?: FileSystem;
43
53
  }
44
- /** Runs installation and auth health checks for supported coding agents. */
54
+ /** Runs installation, liveness, and auth health checks for supported coding agents. */
45
55
  export declare class DoctorRunner {
46
56
  private readonly detector;
47
57
  private readonly runner;
@@ -55,11 +65,5 @@ export declare class DoctorRunner {
55
65
  /** Run a health check on one agent. */
56
66
  runOne(agent: string): Promise<DoctorResult>;
57
67
  private buildResult;
58
- private checkAuth;
59
- private checkCodexAuth;
60
- private geminiSettingsContainCredentials;
61
- private probeAuthOutput;
62
- /** True when the path exists and has a non-zero size. */
63
- private hasNonEmptyFile;
64
68
  }
65
69
  //# sourceMappingURL=doctor-runner.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAwB,KAAK,UAAU,EAA2B,MAAM,uBAAuB,CAAC;AACvG,OAAO,EAAE,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,KAAK,SAAS,EAAiD,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAuB,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5D,gDAAgD;AAChD,MAAM,WAAW,YAAY;IACzB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,SAAS,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,+CAA+C;IAC/C,aAAa,EAAE,OAAO,CAAC;IACvB,iDAAiD;IACjD,MAAM,EAAE,OAAO,CAAC;IAChB,oEAAoE;IACpE,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC;IACZ,wDAAwD;IACxD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gEAAgE;IAChE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wEAAwE;IACxE,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,oCAAoC;IACpC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAChC,uBAAuB;IACvB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,qBAAqB;IACrB,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0GAA0G;IAC1G,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAwCD,4EAA4E;AAC5E,qBAAa,YAAY;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAqC;IACzD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,OAAO,GAAE,mBAAwB;IAS7C,kDAAkD;IAC5C,MAAM,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAkBvC,uCAAuC;IACjC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;YAIpC,WAAW;YAmBX,SAAS;YAcT,cAAc;YASd,gCAAgC;YAQhC,eAAe;IAY7B,yDAAyD;YAC3C,eAAe;CAKhC"}
1
+ {"version":3,"file":"doctor-runner.d.ts","sourceRoot":"","sources":["../src/doctor-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,EAAwB,KAAK,UAAU,EAAiB,MAAM,uBAAuB,CAAC;AAC7F,OAAO,EAAE,aAAa,EAAsB,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,KAAK,SAAS,EAAmB,MAAM,qBAAqB,CAAC;AACtE,OAAO,EAAE,KAAK,SAAS,EAAiD,MAAM,gBAAgB,CAAC;AAC/F,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,gDAAgD;AAChD,MAAM,WAAW,YAAY;IACzB,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,oCAAoC;IACpC,SAAS,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB;;;;;OAKG;IACH,aAAa,EAAE,SAAS,CAAC;IACzB;;;;OAIG;IACH,MAAM,EAAE,OAAO,CAAC;IAChB,oEAAoE;IACpE,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC;IACZ,wDAAwD;IACxD,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,gEAAgE;IAChE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wEAAwE;IACxE,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB,oCAAoC;IACpC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,4CAA4C;AAC5C,MAAM,WAAW,mBAAmB;IAChC,uBAAuB;IACvB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,qBAAqB;IACrB,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,0GAA0G;IAC1G,UAAU,CAAC,EAAE,UAAU,CAAC;CAC3B;AAID,uFAAuF;AACvF,qBAAa,YAAY;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAW;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAqC;IACzD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,OAAO,GAAE,mBAAwB;IAS7C,kDAAkD;IAC5C,MAAM,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;IAkBvC,uCAAuC;IACjC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;YAIpC,WAAW;CA4B5B"}
@@ -1,44 +1,11 @@
1
1
  import { getLogger } from '@gobing-ai/ts-infra';
2
- import { createNodeFileSystem, getProcessEnv, joinPath } from '@gobing-ai/ts-runtime';
2
+ import { createNodeFileSystem, getProcessEnv } from '@gobing-ai/ts-runtime';
3
3
  import { AgentDetector } from './agent-detector.js';
4
+ import { isAuthenticated } from './agents/auth-shims.js';
4
5
  import { DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims.js';
5
6
  import { AiRunner } from './ai-runner.js';
6
7
  const DEFAULT_TIMEOUT_MS = 5_000;
7
- const AUTH_PATTERNS = {
8
- claude: {
9
- positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
10
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
11
- },
12
- codex: {
13
- positive: /logged[\s_-]*in|authenticated/i,
14
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
15
- },
16
- opencode: {
17
- positive: /configured|available/i,
18
- negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
19
- },
20
- openclaw: {
21
- positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
22
- negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
23
- },
24
- pi: {
25
- positive: /\S/,
26
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
27
- },
28
- omp: {
29
- positive: /\S/,
30
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
31
- },
32
- hermes: {
33
- positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
34
- negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
35
- },
36
- };
37
- /** True when a value is a defined, non-blank string. */
38
- function isNonEmpty(value) {
39
- return value !== undefined && value.trim().length > 0;
40
- }
41
- /** Runs installation and auth health checks for supported coding agents. */
8
+ /** Runs installation, liveness, and auth health checks for supported coding agents. */
42
9
  export class DoctorRunner {
43
10
  detector;
44
11
  runner;
@@ -74,13 +41,22 @@ export class DoctorRunner {
74
41
  const canonical = resolveAgentName(detected.name) ?? null;
75
42
  const tier = canonical !== null && TIER2_AGENTS.has(canonical) ? 2 : 1;
76
43
  this.logger.debug('checking agent', { agent: detected.name, installed: detected.installed, tier });
77
- const authenticated = detected.installed && canonical !== null ? await this.checkAuth(canonical) : false;
44
+ const authenticated = detected.installed && canonical !== null
45
+ ? await isAuthenticated(canonical, {
46
+ runner: this.runner,
47
+ env: this.env,
48
+ fileSystem: this.fs,
49
+ timeout: this.timeout,
50
+ })
51
+ : 'unauthenticated';
78
52
  return {
79
53
  agent: detected.name,
80
54
  installed: detected.installed,
81
55
  version: detected.version,
82
56
  authenticated,
83
- usable: detected.installed && detected.version !== null && authenticated,
57
+ // Liveness only: auth never gates runnability. A logged-out agent
58
+ // is usable (it fails at runtime with its own error).
59
+ usable: detected.installed && detected.version !== null,
84
60
  tier,
85
61
  channels: detected.channels,
86
62
  deprecated: detected.deprecated,
@@ -88,54 +64,4 @@ export class DoctorRunner {
88
64
  error: detected.error,
89
65
  };
90
66
  }
91
- async checkAuth(agent) {
92
- const home = this.env.HOME || this.env.USERPROFILE || '';
93
- if (agent === 'gemini')
94
- return this.geminiSettingsContainCredentials(home);
95
- if (agent === 'codex')
96
- return this.checkCodexAuth(home);
97
- // pi and omp read provider keys from the environment; require a non-empty
98
- // value rather than mere presence (an empty export is not a usable credential).
99
- if ((agent === 'pi' || agent === 'omp') &&
100
- (isNonEmpty(this.env.GOOGLE_API_KEY) || isNonEmpty(this.env.ANTHROPIC_API_KEY)))
101
- return true;
102
- return (await this.probeAuthOutput(agent)) === true;
103
- }
104
- async checkCodexAuth(home) {
105
- const probeStatus = await this.probeAuthOutput('codex');
106
- if (probeStatus !== null)
107
- return probeStatus;
108
- return ((await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth.json'))) ||
109
- (await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth'))));
110
- }
111
- async geminiSettingsContainCredentials(home) {
112
- try {
113
- return /auth|token|key/i.test(await this.fs.readFile(joinPath(home, '.gemini', 'settings.json')));
114
- }
115
- catch {
116
- return false;
117
- }
118
- }
119
- async probeAuthOutput(agent) {
120
- const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
121
- const patterns = AUTH_PATTERNS[agent];
122
- if (command === null || patterns === undefined)
123
- return null;
124
- const result = await command;
125
- if (result.exitCode !== 0)
126
- return false;
127
- const output = `${result.stdout}\n${result.stderr}`;
128
- if (patterns.negative.test(output))
129
- return false;
130
- if (patterns.positive.test(output))
131
- return true;
132
- return null;
133
- }
134
- /** True when the path exists and has a non-zero size. */
135
- async hasNonEmptyFile(path) {
136
- const stat = await this.fs.stat(path);
137
- if (stat === null)
138
- return false;
139
- return stat.isFile() && stat.size > 0;
140
- }
141
67
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './agent-detector';
2
2
  export * from './agent-spec';
3
+ export * from './agents/auth-shims';
3
4
  export * from './agents/shims';
4
5
  export * from './ai-runner';
5
6
  export * from './doctor-runner';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './agent-detector.js';
2
2
  export * from './agent-spec.js';
3
+ export * from './agents/auth-shims.js';
3
4
  export * from './agents/shims.js';
4
5
  export * from './ai-runner.js';
5
6
  export * from './doctor-runner.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-ai-runner",
3
- "version": "0.3.21",
3
+ "version": "0.4.1",
4
4
  "description": "@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -47,9 +47,9 @@
47
47
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-ai-runner-v<version> && git push --tags' && exit 1"
48
48
  },
49
49
  "dependencies": {
50
- "@gobing-ai/ts-db": "^0.3.21",
51
- "@gobing-ai/ts-infra": "^0.3.21",
52
- "@gobing-ai/ts-runtime": "^0.3.21"
50
+ "@gobing-ai/ts-db": "^0.4.1",
51
+ "@gobing-ai/ts-infra": "^0.4.1",
52
+ "@gobing-ai/ts-runtime": "^0.4.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/bun": "1.3.14"
@@ -0,0 +1,153 @@
1
+ import { type FileSystem, getProcessEnv, joinPath } from '@gobing-ai/ts-runtime';
2
+ import type { AgentRunOptions, AgentRunResult, AiRunner } from '../ai-runner';
3
+ import { type AgentName, getAgentShim } from './shims';
4
+
5
+ /**
6
+ * Authentication state for a coding agent — **tri-state by design**.
7
+ *
8
+ * `unknown` is first-class: several agents have no reliable auth probe
9
+ * (no auth verb, no checkable credential file). Collapsing `unknown` to
10
+ * `unauthenticated` is exactly the false-negative that used to make
11
+ * `antigravity-cli` (no auth verb) and inconclusive probes report
12
+ * `usable: false` despite being runnable.
13
+ *
14
+ * Auth detection is **off the execution critical path**: {@link isAuthenticated}
15
+ * never feeds run-readiness — it is operator information only. A genuinely
16
+ * unauthenticated agent fails at runtime with its own error (auth is the
17
+ * agent's concern).
18
+ */
19
+ export type AuthState = 'authenticated' | 'unauthenticated' | 'unknown';
20
+
21
+ /** Context injected into {@link isAuthenticated}. */
22
+ export interface AuthContext {
23
+ /** Runner used to execute auth probes. */
24
+ runner: AiRunner;
25
+ /** Environment map for env/file auth checks. Defaults to the process env. */
26
+ env?: Record<string, string | undefined>;
27
+ /** Filesystem for auth-file checks. Required — inject a fake in tests. */
28
+ fileSystem: FileSystem;
29
+ /** Auth probe timeout in milliseconds. */
30
+ timeout?: number;
31
+ }
32
+
33
+ const DEFAULT_AUTH_TIMEOUT_MS = 5_000;
34
+
35
+ /**
36
+ * Per-agent auth-probe output patterns. A positive match ⇒ authenticated;
37
+ * a negative match ⇒ unauthenticated; neither (or no probe) ⇒ unknown.
38
+ */
39
+ const AUTH_PATTERNS: Partial<Record<AgentName, { positive: RegExp; negative: RegExp }>> = {
40
+ claude: {
41
+ positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
42
+ negative:
43
+ /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
44
+ },
45
+ codex: {
46
+ positive: /logged[\s_-]*in|authenticated/i,
47
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
48
+ },
49
+ opencode: {
50
+ positive: /configured|available/i,
51
+ negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
52
+ },
53
+ openclaw: {
54
+ positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
55
+ negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
56
+ },
57
+ pi: {
58
+ positive: /\S/,
59
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
60
+ },
61
+ omp: {
62
+ positive: /\S/,
63
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
64
+ },
65
+ hermes: {
66
+ positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
67
+ negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
68
+ },
69
+ };
70
+
71
+ /** True when a value is a defined, non-blank string. */
72
+ function isNonEmpty(value: string | undefined): boolean {
73
+ return value !== undefined && value.trim().length > 0;
74
+ }
75
+
76
+ /**
77
+ * Resolve a coding agent's authentication state.
78
+ *
79
+ * **Never throws.** Any probe failure, inconclusive output, or missing probe
80
+ * resolves to `unknown` rather than `unauthenticated` — see {@link AuthState}.
81
+ *
82
+ * Auth sources, in priority order per agent:
83
+ * - **gemini** — credential-like content in `~/.gemini/settings.json`.
84
+ * - **codex** — `codex login status` CLI output, falling back to an
85
+ * `~/.codex/auth{.json,}` credential file.
86
+ * - **pi / omp** — a non-empty `GOOGLE_API_KEY` or `ANTHROPIC_API_KEY` in env
87
+ * (an empty export is not a usable credential), else the CLI auth probe.
88
+ * - **others** — the shim's auth command; matched against {@link AUTH_PATTERNS}.
89
+ * - **no probe** (e.g. `antigravity-cli`) ⇒ `unknown`.
90
+ */
91
+ export async function isAuthenticated(agent: AgentName, ctx: AuthContext): Promise<AuthState> {
92
+ const env = ctx.env ?? getProcessEnv();
93
+ const fs = ctx.fileSystem;
94
+ const timeout = ctx.timeout ?? DEFAULT_AUTH_TIMEOUT_MS;
95
+ const home = env.HOME || env.USERPROFILE || '';
96
+
97
+ if (agent === 'gemini') return geminiSettingsContainCredentials(fs, home);
98
+ if (agent === 'codex') return checkCodexAuth(ctx.runner, fs, home, timeout);
99
+
100
+ // pi and omp read provider keys from the environment; require a non-empty
101
+ // value rather than mere presence (an empty export is not a usable credential).
102
+ if ((agent === 'pi' || agent === 'omp') && (isNonEmpty(env.GOOGLE_API_KEY) || isNonEmpty(env.ANTHROPIC_API_KEY))) {
103
+ return 'authenticated';
104
+ }
105
+
106
+ return probeAuthOutput(ctx.runner, agent, timeout);
107
+ }
108
+
109
+ async function checkCodexAuth(runner: AiRunner, fs: FileSystem, home: string, timeout: number): Promise<AuthState> {
110
+ const probeStatus = await probeAuthOutput(runner, 'codex', timeout);
111
+ if (probeStatus !== 'unknown') return probeStatus;
112
+ const hasFile =
113
+ (await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth.json'))) ||
114
+ (await hasNonEmptyFile(fs, joinPath(home, '.codex', 'auth')));
115
+ return hasFile ? 'authenticated' : 'unknown';
116
+ }
117
+
118
+ async function geminiSettingsContainCredentials(fs: FileSystem, home: string): Promise<AuthState> {
119
+ try {
120
+ const content = await fs.readFile(joinPath(home, '.gemini', 'settings.json'));
121
+ return /auth|token|key/i.test(content) ? 'authenticated' : 'unauthenticated';
122
+ } catch {
123
+ // Missing or unreadable settings — auth state genuinely unknown.
124
+ return 'unknown';
125
+ }
126
+ }
127
+
128
+ async function probeAuthOutput(runner: AiRunner, agent: AgentName, timeout: number): Promise<AuthState> {
129
+ const options: AgentRunOptions = { timeout };
130
+ const command: Promise<AgentRunResult> | null = runner.runAuthCommand(agent, options);
131
+ const patterns = AUTH_PATTERNS[agent];
132
+ if (command === null || patterns === undefined) return 'unknown';
133
+ try {
134
+ const result = await command;
135
+ if (result.exitCode !== 0) return 'unauthenticated';
136
+ const output = `${result.stdout}\n${result.stderr}`;
137
+ if (patterns.negative.test(output)) return 'unauthenticated';
138
+ if (patterns.positive.test(output)) return 'authenticated';
139
+ return 'unknown';
140
+ } catch {
141
+ return 'unknown';
142
+ }
143
+ }
144
+
145
+ /** True when the path exists and has a non-zero size. */
146
+ async function hasNonEmptyFile(fs: FileSystem, path: string): Promise<boolean> {
147
+ const stat = await fs.stat(path);
148
+ if (stat === null) return false;
149
+ return stat.isFile() && stat.size > 0;
150
+ }
151
+
152
+ // Re-exported so callers that only need the auth facet don't import getAgentShim directly.
153
+ export { getAgentShim };
package/src/ai-runner.ts CHANGED
@@ -31,6 +31,8 @@ export interface AgentRunOptions {
31
31
  cwd?: string;
32
32
  /** Timeout in milliseconds. */
33
33
  timeout?: number;
34
+ /** AbortSignal forwarded to ProcessExecutor — aborts the agent subprocess when fired. */
35
+ signal?: AbortSignal;
34
36
  }
35
37
 
36
38
  /** Constructor options for AiRunner. */
@@ -142,6 +144,7 @@ export class AiRunner {
142
144
  forceBuffered,
143
145
  cwd: options.cwd ?? this.defaultCwd,
144
146
  timeout: options.timeout ?? this.defaultTimeout,
147
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
145
148
  });
146
149
  if (result.exitCode !== 0) {
147
150
  this.logger.error('invoke exited non-zero', {
@@ -1,8 +1,9 @@
1
1
  import { getLogger, type Logger } from '@gobing-ai/ts-infra';
2
- import { createNodeFileSystem, type FileSystem, getProcessEnv, joinPath } from '@gobing-ai/ts-runtime';
2
+ import { createNodeFileSystem, type FileSystem, getProcessEnv } from '@gobing-ai/ts-runtime';
3
3
  import { AgentDetector, type DetectedAgent } from './agent-detector';
4
+ import { type AuthState, isAuthenticated } from './agents/auth-shims';
4
5
  import { type AgentName, DISPLAY_ORDER, resolveAgentName, TIER2_AGENTS } from './agents/shims';
5
- import { type AgentRunResult, AiRunner } from './ai-runner';
6
+ import { AiRunner } from './ai-runner';
6
7
 
7
8
  /** Health-check result for one coding agent. */
8
9
  export interface DoctorResult {
@@ -12,9 +13,18 @@ export interface DoctorResult {
12
13
  installed: boolean;
13
14
  /** Human-readable version when installed. */
14
15
  version: string | null;
15
- /** Whether the agent appears authenticated. */
16
- authenticated: boolean;
17
- /** Whether the agent is ready for direct use. */
16
+ /**
17
+ * Authentication state — **tri-state** (`authenticated | unauthenticated |
18
+ * unknown`). Informational only; never feeds {@link usable} or any
19
+ * run-readiness gate. A genuinely unauthenticated agent fails at runtime
20
+ * with its own error.
21
+ */
22
+ authenticated: AuthState;
23
+ /**
24
+ * Whether the agent is **runnable** — liveness only
25
+ * (`installed && version !== null`). Auth is NOT consulted: a logged-out
26
+ * agent is still `usable` (it will fail at runtime with its own auth error).
27
+ */
18
28
  usable: boolean;
19
29
  /** Capability tier: 1 = direct CLI, 2 = gateway/TUI constrained. */
20
30
  tier: 1 | 2;
@@ -45,44 +55,8 @@ export interface DoctorRunnerOptions {
45
55
  }
46
56
 
47
57
  const DEFAULT_TIMEOUT_MS = 5_000;
48
- const AUTH_PATTERNS: Partial<Record<AgentName, { positive: RegExp; negative: RegExp }>> = {
49
- claude: {
50
- positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
51
- negative:
52
- /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
53
- },
54
- codex: {
55
- positive: /logged[\s_-]*in|authenticated/i,
56
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
57
- },
58
- opencode: {
59
- positive: /configured|available/i,
60
- negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
61
- },
62
- openclaw: {
63
- positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
64
- negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
65
- },
66
- pi: {
67
- positive: /\S/,
68
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
69
- },
70
- omp: {
71
- positive: /\S/,
72
- negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
73
- },
74
- hermes: {
75
- positive: /(^|[^a-z])ok([^a-z]|$)|healthy|configured|ready/i,
76
- negative: /not[\s_-]*(configured|healthy|ok)|unhealthy|missing[\s_-]+dependenc|error|failed/i,
77
- },
78
- };
79
58
 
80
- /** True when a value is a defined, non-blank string. */
81
- function isNonEmpty(value: string | undefined): boolean {
82
- return value !== undefined && value.trim().length > 0;
83
- }
84
-
85
- /** Runs installation and auth health checks for supported coding agents. */
59
+ /** Runs installation, liveness, and auth health checks for supported coding agents. */
86
60
  export class DoctorRunner {
87
61
  private readonly detector: AgentDetector;
88
62
  private readonly runner: AiRunner;
@@ -128,13 +102,23 @@ export class DoctorRunner {
128
102
  const canonical = resolveAgentName(detected.name) ?? null;
129
103
  const tier = canonical !== null && TIER2_AGENTS.has(canonical) ? 2 : 1;
130
104
  this.logger.debug('checking agent', { agent: detected.name, installed: detected.installed, tier });
131
- const authenticated = detected.installed && canonical !== null ? await this.checkAuth(canonical) : false;
105
+ const authenticated: AuthState =
106
+ detected.installed && canonical !== null
107
+ ? await isAuthenticated(canonical, {
108
+ runner: this.runner,
109
+ env: this.env,
110
+ fileSystem: this.fs,
111
+ timeout: this.timeout,
112
+ })
113
+ : 'unauthenticated';
132
114
  return {
133
115
  agent: detected.name,
134
116
  installed: detected.installed,
135
117
  version: detected.version,
136
118
  authenticated,
137
- usable: detected.installed && detected.version !== null && authenticated,
119
+ // Liveness only: auth never gates runnability. A logged-out agent
120
+ // is usable (it fails at runtime with its own error).
121
+ usable: detected.installed && detected.version !== null,
138
122
  tier,
139
123
  channels: detected.channels,
140
124
  deprecated: detected.deprecated,
@@ -142,54 +126,4 @@ export class DoctorRunner {
142
126
  error: detected.error,
143
127
  };
144
128
  }
145
-
146
- private async checkAuth(agent: AgentName): Promise<boolean> {
147
- const home = this.env.HOME || this.env.USERPROFILE || '';
148
- if (agent === 'gemini') return this.geminiSettingsContainCredentials(home);
149
- if (agent === 'codex') return this.checkCodexAuth(home);
150
- // pi and omp read provider keys from the environment; require a non-empty
151
- // value rather than mere presence (an empty export is not a usable credential).
152
- if (
153
- (agent === 'pi' || agent === 'omp') &&
154
- (isNonEmpty(this.env.GOOGLE_API_KEY) || isNonEmpty(this.env.ANTHROPIC_API_KEY))
155
- )
156
- return true;
157
- return (await this.probeAuthOutput(agent)) === true;
158
- }
159
-
160
- private async checkCodexAuth(home: string): Promise<boolean> {
161
- const probeStatus = await this.probeAuthOutput('codex');
162
- if (probeStatus !== null) return probeStatus;
163
- return (
164
- (await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth.json'))) ||
165
- (await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth')))
166
- );
167
- }
168
-
169
- private async geminiSettingsContainCredentials(home: string): Promise<boolean> {
170
- try {
171
- return /auth|token|key/i.test(await this.fs.readFile(joinPath(home, '.gemini', 'settings.json')));
172
- } catch {
173
- return false;
174
- }
175
- }
176
-
177
- private async probeAuthOutput(agent: AgentName): Promise<boolean | null> {
178
- const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
179
- const patterns = AUTH_PATTERNS[agent];
180
- if (command === null || patterns === undefined) return null;
181
- const result: AgentRunResult = await command;
182
- if (result.exitCode !== 0) return false;
183
- const output = `${result.stdout}\n${result.stderr}`;
184
- if (patterns.negative.test(output)) return false;
185
- if (patterns.positive.test(output)) return true;
186
- return null;
187
- }
188
-
189
- /** True when the path exists and has a non-zero size. */
190
- private async hasNonEmptyFile(path: string): Promise<boolean> {
191
- const stat = await this.fs.stat(path);
192
- if (stat === null) return false;
193
- return stat.isFile() && stat.size > 0;
194
- }
195
129
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './agent-detector';
2
2
  export * from './agent-spec';
3
+ export * from './agents/auth-shims';
3
4
  export * from './agents/shims';
4
5
  export * from './ai-runner';
5
6
  export * from './doctor-runner';