@gobing-ai/ts-ai-runner 0.3.1 → 0.3.2

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.
Files changed (46) hide show
  1. package/README.md +336 -32
  2. package/dist/agent-detector.d.ts +1 -0
  3. package/dist/agent-detector.d.ts.map +1 -1
  4. package/dist/agent-detector.js +13 -5
  5. package/dist/agent-spec.d.ts +6 -0
  6. package/dist/agent-spec.d.ts.map +1 -1
  7. package/dist/agent-spec.js +12 -8
  8. package/dist/ai-runner.d.ts +18 -2
  9. package/dist/ai-runner.d.ts.map +1 -1
  10. package/dist/ai-runner.js +52 -6
  11. package/dist/doctor-runner.d.ts +7 -0
  12. package/dist/doctor-runner.d.ts.map +1 -1
  13. package/dist/doctor-runner.js +69 -15
  14. package/dist/events.d.ts +38 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +0 -0
  17. package/dist/identity.d.ts +3 -0
  18. package/dist/identity.d.ts.map +1 -1
  19. package/dist/identity.js +2 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/messages.d.ts +4 -0
  24. package/dist/messages.d.ts.map +1 -0
  25. package/dist/messages.js +4 -0
  26. package/dist/team-agent-process.d.ts +12 -4
  27. package/dist/team-agent-process.d.ts.map +1 -1
  28. package/dist/team-agent-process.js +31 -19
  29. package/dist/team-orchestrator.d.ts +13 -8
  30. package/dist/team-orchestrator.d.ts.map +1 -1
  31. package/dist/team-orchestrator.js +32 -25
  32. package/package.json +4 -4
  33. package/src/agent-detector.ts +14 -5
  34. package/src/agent-spec.ts +20 -8
  35. package/src/ai-runner.ts +75 -13
  36. package/src/doctor-runner.ts +77 -16
  37. package/src/events.ts +25 -0
  38. package/src/identity.ts +3 -0
  39. package/src/index.ts +2 -1
  40. package/src/messages.ts +6 -0
  41. package/src/team-agent-process.ts +36 -21
  42. package/src/team-orchestrator.ts +36 -25
  43. package/dist/message-service.d.ts +0 -13
  44. package/dist/message-service.d.ts.map +0 -1
  45. package/dist/message-service.js +0 -27
  46. package/src/message-service.ts +0 -33
@@ -1,20 +1,26 @@
1
+ import type { InboxMessageDao } from '@gobing-ai/ts-db/inbox';
2
+ import { EventBus } from '@gobing-ai/ts-infra';
1
3
  import type { AgentSpec } from './agent-spec';
2
- import { MessageService } from './message-service';
4
+ import type { AgentEvents } from './events';
3
5
  import { TeamAgentProcess } from './team-agent-process';
4
- type TeamEvent = 'agent.started' | 'agent.stopped' | 'message.sent';
5
- type TeamListener = (payload: unknown) => void;
6
6
  type AgentProcessFactory = (options: ConstructorParameters<typeof TeamAgentProcess>[0]) => TeamAgentProcess;
7
+ /** Configuration options for `TeamOrchestrator`. */
7
8
  export interface TeamOrchestratorOptions {
8
9
  processFactory?: AgentProcessFactory;
10
+ events?: EventBus<AgentEvents>;
9
11
  }
12
+ /**
13
+ * Orchestrates a team of AI agents — loads specs, starts/stops agent processes, routes messages between them,
14
+ * and emits lifecycle events. Agents in the same workspace see each other as peers.
15
+ */
10
16
  export declare class TeamOrchestrator {
11
17
  private readonly configDir;
12
- private readonly messageService;
18
+ private readonly inbox;
13
19
  private specs;
14
20
  private readonly running;
15
- private readonly listeners;
16
21
  private readonly processFactory;
17
- constructor(configDir: string, messageService: MessageService, options?: TeamOrchestratorOptions);
22
+ private readonly events;
23
+ constructor(configDir: string, inbox: InboxMessageDao, options?: TeamOrchestratorOptions);
18
24
  loadSpecs(): AgentSpec[];
19
25
  getSpec(id: string): AgentSpec | undefined;
20
26
  startAgent(id: string): Promise<TeamAgentProcess>;
@@ -25,12 +31,11 @@ export declare class TeamOrchestrator {
25
31
  getAgentStatus(id: string): 'running' | 'stopped' | 'errored' | 'unknown';
26
32
  getPeerSpecs(workspace: string, excludeId?: string): AgentSpec[];
27
33
  stopAll(): Promise<void>;
28
- on(event: TeamEvent, listener: TeamListener): () => void;
34
+ on<K extends keyof AgentEvents>(event: K, listener: AgentEvents[K]): () => void;
29
35
  private requireSpec;
30
36
  private requireAgentName;
31
37
  private injectPendingMessages;
32
38
  private flushInbox;
33
- private emit;
34
39
  }
35
40
  export {};
36
41
  //# sourceMappingURL=team-orchestrator.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"team-orchestrator.d.ts","sourceRoot":"","sources":["../src/team-orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAI9C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAExD,KAAK,SAAS,GAAG,eAAe,GAAG,eAAe,GAAG,cAAc,CAAC;AACpE,KAAK,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;AAC/C,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE,qBAAqB,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,KAAK,gBAAgB,CAAC;AAE5G,MAAM,WAAW,uBAAuB;IACpC,cAAc,CAAC,EAAE,mBAAmB,CAAC;CACxC;AAED,qBAAa,gBAAgB;IAOrB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,cAAc;IAPnC,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAC/D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA2C;IACrE,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsB;gBAGhC,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,cAAc,EAC/C,OAAO,GAAE,uBAA4B;IAKzC,SAAS,IAAI,SAAS,EAAE;IAKxB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAKpC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAkCjD,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQpC,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAKnD,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQzG,gBAAgB,IAAI,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAIjD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS;IAMzE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE;IAK1D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9B,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;IAOxD,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,gBAAgB;IAKxB,OAAO,CAAC,qBAAqB;YAIf,UAAU;IASxB,OAAO,CAAC,IAAI;CAGf"}
1
+ {"version":3,"file":"team-orchestrator.d.ts","sourceRoot":"","sources":["../src/team-orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAExD,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE,qBAAqB,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,KAAK,gBAAgB,CAAC;AAE5G,oDAAoD;AACpD,MAAM,WAAW,uBAAuB;IACpC,cAAc,CAAC,EAAE,mBAAmB,CAAC;IACrC,MAAM,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;CAClC;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAOrB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK;IAP1B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAC/D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsB;IACrD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAwB;gBAG1B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,EACvC,OAAO,GAAE,uBAA4B;IAMzC,SAAS,IAAI,SAAS,EAAE;IAKxB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAKpC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAsCjD,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQpC,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAKnD,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQzG,gBAAgB,IAAI,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAIjD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS;IAMzE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE;IAK1D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9B,EAAE,CAAC,CAAC,SAAS,MAAM,WAAW,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IAK/E,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,gBAAgB;YAKV,qBAAqB;YAIrB,UAAU;CAa3B"}
@@ -1,19 +1,25 @@
1
+ import { EventBus } from '@gobing-ai/ts-infra';
1
2
  import { loadAgentSpecs } from './agent-spec.js';
2
3
  import { getAgentShim, isAgentName } from './agents/shims.js';
3
4
  import { buildIdentityPreamble } from './identity.js';
4
- import { MessageService } from './message-service.js';
5
+ import { formatMessage } from './messages.js';
5
6
  import { TeamAgentProcess } from './team-agent-process.js';
7
+ /**
8
+ * Orchestrates a team of AI agents — loads specs, starts/stops agent processes, routes messages between them,
9
+ * and emits lifecycle events. Agents in the same workspace see each other as peers.
10
+ */
6
11
  export class TeamOrchestrator {
7
12
  configDir;
8
- messageService;
13
+ inbox;
9
14
  specs = [];
10
15
  running = new Map();
11
- listeners = new Map();
12
16
  processFactory;
13
- constructor(configDir, messageService, options = {}) {
17
+ events;
18
+ constructor(configDir, inbox, options = {}) {
14
19
  this.configDir = configDir;
15
- this.messageService = messageService;
20
+ this.inbox = inbox;
16
21
  this.processFactory = options.processFactory ?? ((processOptions) => new TeamAgentProcess(processOptions));
22
+ this.events = options.events ?? new EventBus();
17
23
  }
18
24
  loadSpecs() {
19
25
  this.specs = loadAgentSpecs(this.configDir);
@@ -54,7 +60,11 @@ export class TeamOrchestrator {
54
60
  await process.start();
55
61
  this.running.set(id, process);
56
62
  await this.injectPendingMessages(process);
57
- this.emit('agent.started', { id });
63
+ void this.events.emit('agent.started', {
64
+ agentId: spec.id,
65
+ agentType: spec.type,
66
+ pid: process.getPid(),
67
+ });
58
68
  return process;
59
69
  }
60
70
  async stopAgent(id) {
@@ -63,18 +73,17 @@ export class TeamOrchestrator {
63
73
  return;
64
74
  await process.stop();
65
75
  this.running.delete(id);
66
- this.emit('agent.stopped', { id });
76
+ void this.events.emit('agent.stopped', { agentId: id, exitCode: process.getExitCode() });
67
77
  }
68
78
  async restartAgent(id) {
69
79
  await this.stopAgent(id);
70
80
  return this.startAgent(id);
71
81
  }
72
82
  async sendMessage(fromId, toId, body, inReplyTo) {
73
- const msgId = await this.messageService.enqueue(fromId, toId, body, inReplyTo);
83
+ const msgId = await this.inbox.enqueue(fromId, toId, body, inReplyTo);
74
84
  const process = this.running.get(toId);
75
- if (process !== undefined)
76
- await this.flushInbox(process, 'live stdin injection failed');
77
- this.emit('message.sent', { id: msgId, fromId, toId });
85
+ const ok = process !== undefined ? await this.flushInbox(process, 'live stdin injection failed') : false;
86
+ void this.events.emit('agent.message.sent', { agentId: toId, ok });
78
87
  return msgId;
79
88
  }
80
89
  getRunningAgents() {
@@ -95,10 +104,8 @@ export class TeamOrchestrator {
95
104
  await Promise.all([...this.running.keys()].map((id) => this.stopAgent(id)));
96
105
  }
97
106
  on(event, listener) {
98
- const listeners = this.listeners.get(event) ?? new Set();
99
- listeners.add(listener);
100
- this.listeners.set(event, listeners);
101
- return () => listeners.delete(listener);
107
+ this.events.on(event, listener);
108
+ return () => this.events.off(event, listener);
102
109
  }
103
110
  requireSpec(id) {
104
111
  const spec = this.getSpec(id);
@@ -111,21 +118,21 @@ export class TeamOrchestrator {
111
118
  throw new Error(`Unsupported agent type: ${type}`);
112
119
  return type;
113
120
  }
114
- injectPendingMessages(process) {
121
+ async injectPendingMessages(process) {
115
122
  return this.flushInbox(process, 'startup stdin injection failed');
116
123
  }
117
124
  async flushInbox(process, failLabel) {
118
- const messages = await this.messageService.drain(process.agentId);
125
+ const messages = await this.inbox.drainPending(process.agentId);
126
+ let ok = true;
119
127
  for (const message of messages) {
120
- const result = await process.send(MessageService.formatMessage(message));
128
+ const result = await process.send(formatMessage(message));
121
129
  if (result.ok)
122
- await this.messageService.deliver(message.id);
123
- else
124
- await this.messageService.fail(message.id, failLabel);
130
+ await this.inbox.markDelivered(message.id);
131
+ else {
132
+ ok = false;
133
+ await this.inbox.markFailed(message.id, failLabel);
134
+ }
125
135
  }
126
- }
127
- emit(event, payload) {
128
- for (const listener of this.listeners.get(event) ?? [])
129
- listener(payload);
136
+ return ok;
130
137
  }
131
138
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-ai-runner",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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.1",
51
- "@gobing-ai/ts-runtime": "^0.3.1",
52
- "@gobing-ai/ts-utils": "^0.3.1"
50
+ "@gobing-ai/ts-db": "^0.3.2",
51
+ "@gobing-ai/ts-infra": "^0.3.2",
52
+ "@gobing-ai/ts-runtime": "^0.3.2"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/bun": "1.3.14"
@@ -58,24 +58,33 @@ export class AgentDetector {
58
58
  if (lower.includes('command not found') || lower.includes('enoent') || lower.includes('not recognized')) {
59
59
  return unavailable(agent, `${command}: command not found`);
60
60
  }
61
- if (result.signal !== undefined || result.exitCode === null) {
62
- return unavailable(agent, result.signal ?? 'Process timed out');
61
+ if (result.signal !== undefined) {
62
+ return unavailable(agent, `Terminated by signal: ${result.signal}`);
63
+ }
64
+ if (result.exitCode === null) {
65
+ return unavailable(agent, 'Process did not produce an exit code');
63
66
  }
64
67
  if (result.exitCode !== 0) {
65
- return unavailable(agent, `Non-zero exit code ${result.exitCode}: ${result.stderr.slice(0, 200)}`);
68
+ return unavailable(agent, `Non-zero exit code: ${result.exitCode}. stderr: ${result.stderr.slice(0, 200)}`);
66
69
  }
67
70
  const match = VERSION_PATTERN.exec(output);
68
71
  if (match?.groups?.version === undefined) {
69
72
  return unavailable(agent, 'Could not parse version output');
70
73
  }
74
+ const version = output.split('\n')[0]?.trim() || match.groups.version;
71
75
  return {
72
76
  name: agent,
73
77
  installed: true,
74
- version: match.groups.version,
75
- channels: [],
78
+ version,
79
+ channels: this.detectChannels(agent, output),
76
80
  error: null,
77
81
  };
78
82
  }
83
+
84
+ private detectChannels(_agent: AgentName, _output: string): string[] {
85
+ // Phase 2 hook: parse per-agent channel/model output here when shims expose it.
86
+ return [];
87
+ }
79
88
  }
80
89
 
81
90
  /** Build an "unavailable" detection result for an agent with the given error. */
package/src/agent-spec.ts CHANGED
@@ -1,6 +1,13 @@
1
- import { basename, join } from 'node:path';
2
- import { NodeSyncFileSystem, parseYamlObject, type SyncFileSystem, stringifyYamlObject } from '@gobing-ai/ts-runtime';
1
+ import {
2
+ basenamePath,
3
+ joinPath,
4
+ NodeSyncFileSystem,
5
+ parseYamlObject,
6
+ type SyncFileSystem,
7
+ stringifyYamlObject,
8
+ } from '@gobing-ai/ts-runtime';
3
9
 
10
+ /** Specification for an AI agent defined in the configuration directory. */
4
11
  export interface AgentSpec {
5
12
  id: string;
6
13
  name: string;
@@ -12,6 +19,7 @@ export interface AgentSpec {
12
19
  autoStart?: boolean;
13
20
  }
14
21
 
22
+ /** Validation error thrown when agent spec data is malformed or invalid. */
15
23
  export class ValueError extends Error {
16
24
  constructor(message: string) {
17
25
  super(message);
@@ -19,6 +27,7 @@ export class ValueError extends Error {
19
27
  }
20
28
  }
21
29
 
30
+ /** Validate that `id` matches the agent ID format: 2-64 chars, lowercase alphanumeric with `_` or `-`. Returns the id on success, throws `ValueError` otherwise. */
22
31
  export function validateAgentId(id: string): string {
23
32
  if (!/^[a-z][a-z0-9_-]{1,63}$/.test(id)) {
24
33
  throw new ValueError(`Invalid agent id "${id}": expected 2-64 chars, lowercase alphanumeric, "_" or "-"`);
@@ -26,11 +35,12 @@ export function validateAgentId(id: string): string {
26
35
  return id;
27
36
  }
28
37
 
38
+ /** Load and validate all YAML agent spec files from `configDir`. Throws `ValueError` on parse failures or duplicate IDs. */
29
39
  export function loadAgentSpecs(configDir: string, fs: SyncFileSystem = new NodeSyncFileSystem()): AgentSpec[] {
30
40
  const entries = safeReadDir(configDir, fs)
31
41
  .filter((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml'))
32
42
  .sort();
33
- const specs = entries.map((entry) => parseAgentSpec(fs.readFile(join(configDir, entry)), entry));
43
+ const specs = entries.map((entry) => parseAgentSpec(fs.readFile(joinPath(configDir, entry)), entry));
34
44
  const seen = new Set<string>();
35
45
  for (const spec of specs) {
36
46
  validateAgentId(spec.id);
@@ -40,6 +50,7 @@ export function loadAgentSpecs(configDir: string, fs: SyncFileSystem = new NodeS
40
50
  return specs;
41
51
  }
42
52
 
53
+ /** Serialize `spec` to YAML and write it to `configDir/<id>.yaml`. Creates the directory if missing. */
43
54
  export async function saveAgentSpec(
44
55
  spec: AgentSpec,
45
56
  configDir: string,
@@ -47,16 +58,17 @@ export async function saveAgentSpec(
47
58
  ): Promise<void> {
48
59
  validateAgentId(spec.id);
49
60
  fs.mkdir(configDir);
50
- fs.writeFile(join(configDir, `${spec.id}.yaml`), serializeAgentSpec(spec));
61
+ fs.writeFile(joinPath(configDir, `${spec.id}.yaml`), serializeAgentSpec(spec));
51
62
  }
52
63
 
64
+ /** Remove the YAML file for agent `id` from `configDir`. Does nothing if the file doesn't exist. */
53
65
  export async function deleteAgentSpec(
54
66
  id: string,
55
67
  configDir: string,
56
68
  fs: SyncFileSystem = new NodeSyncFileSystem(),
57
69
  ): Promise<void> {
58
70
  validateAgentId(id);
59
- fs.unlink(join(configDir, `${id}.yaml`));
71
+ fs.unlink(joinPath(configDir, `${id}.yaml`));
60
72
  }
61
73
 
62
74
  function safeReadDir(configDir: string, fs: SyncFileSystem = new NodeSyncFileSystem()): string[] {
@@ -102,7 +114,7 @@ function serializeAgentSpec(spec: AgentSpec): string {
102
114
  function requireString(source: Record<string, unknown>, key: keyof AgentSpec, fileName: string): string {
103
115
  const value = source[key];
104
116
  if (typeof value !== 'string' || value.trim() === '') {
105
- throw new ValueError(`${basename(fileName)}: "${key}" must be a non-empty string`);
117
+ throw new ValueError(`${basenamePath(fileName)}: "${key}" must be a non-empty string`);
106
118
  }
107
119
  return value;
108
120
  }
@@ -110,7 +122,7 @@ function requireString(source: Record<string, unknown>, key: keyof AgentSpec, fi
110
122
  function requireStringArray(source: Record<string, unknown>, key: keyof AgentSpec, fileName: string): string[] {
111
123
  const value = source[key];
112
124
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
113
- throw new ValueError(`${basename(fileName)}: "${key}" must be a string array`);
125
+ throw new ValueError(`${basenamePath(fileName)}: "${key}" must be a string array`);
114
126
  }
115
127
  return value;
116
128
  }
@@ -122,7 +134,7 @@ function requireRecord(
122
134
  ): Record<string, unknown> {
123
135
  const value = source[key];
124
136
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
125
- throw new ValueError(`${basename(fileName)}: "${key}" must be an object`);
137
+ throw new ValueError(`${basenamePath(fileName)}: "${key}" must be an object`);
126
138
  }
127
139
  return value as Record<string, unknown>;
128
140
  }
package/src/ai-runner.ts CHANGED
@@ -1,6 +1,15 @@
1
- import { NodeProcessExecutor, type ProcessExecutor, type ProcessResult } from '@gobing-ai/ts-runtime';
2
- import { type AgentName, getAgentShim, type PromptOptions } from './agents/shims';
1
+ import { type EventBus, getLogger, type Logger, traceAsync } from '@gobing-ai/ts-infra';
2
+ import {
3
+ getProcessCwd,
4
+ NodeProcessExecutor,
5
+ type ProcessExecutor,
6
+ type ProcessResult,
7
+ type TracerPort,
8
+ } from '@gobing-ai/ts-runtime';
9
+ import { type AgentName, getAgentShim, type PromptOptions, type ShimCommand } from './agents/shims';
10
+ import type { AgentEvents, AiRunnerProcessEvents } from './events';
3
11
  import { buildIdentityPreamble } from './identity';
12
+ import { translateSlashCommand } from './slash-command';
4
13
 
5
14
  /** Result returned by every AI runner dispatch method. */
6
15
  export interface AgentRunResult {
@@ -32,6 +41,14 @@ export interface AiRunnerOptions {
32
41
  defaultCwd?: string;
33
42
  /** Default timeout in milliseconds. */
34
43
  defaultTimeout?: number;
44
+ /** Logger for invocation diagnostics. Defaults to `getLogger('ai-runner')`. */
45
+ logger?: Logger;
46
+ /** Event bus receiving process-level observability from the default executor. */
47
+ processEvents?: EventBus<AiRunnerProcessEvents>;
48
+ /** Event bus receiving agent-level invocation observability. */
49
+ events?: EventBus<AgentEvents>;
50
+ /** Tracer adapter for the default executor. Defaults to `ts-infra` traceAsync. */
51
+ tracer?: TracerPort;
35
52
  }
36
53
 
37
54
  /** Dispatches coding-agent CLI commands through pure command shims. */
@@ -39,11 +56,30 @@ export class AiRunner {
39
56
  private readonly processExecutor: ProcessExecutor;
40
57
  private readonly defaultCwd: string | undefined;
41
58
  private readonly defaultTimeout: number | undefined;
59
+ private readonly logger: Logger;
60
+ private readonly events: EventBus<AgentEvents> | undefined;
42
61
 
43
62
  constructor(options: AiRunnerOptions = {}) {
44
- this.processExecutor = options.processExecutor ?? new NodeProcessExecutor();
63
+ this.processExecutor =
64
+ options.processExecutor ??
65
+ new NodeProcessExecutor({
66
+ ...(options.processEvents !== undefined
67
+ ? {
68
+ events: {
69
+ emit: (event, detail) => {
70
+ void options.processEvents?.emit(event, detail);
71
+ },
72
+ },
73
+ }
74
+ : {}),
75
+ tracer: options.tracer ?? {
76
+ traceAsync: async (name, fn) => await traceAsync(name, (span) => fn(span)),
77
+ },
78
+ });
45
79
  this.defaultCwd = options.defaultCwd;
46
80
  this.defaultTimeout = options.defaultTimeout;
81
+ this.logger = options.logger ?? getLogger('ai-runner');
82
+ this.events = options.events;
47
83
  }
48
84
 
49
85
  /** Run an agent help command. */
@@ -62,14 +98,22 @@ export class AiRunner {
62
98
  promptOptions: PromptOptions,
63
99
  options: AgentRunOptions = {},
64
100
  ): Promise<AgentRunResult> {
65
- const enrichedPromptOptions = this.withIdentityPreamble(agent, promptOptions, options);
66
- return this.invoke(
67
- agent,
68
- 'prompt',
69
- getAgentShim(agent).getPromptCommand(enrichedPromptOptions),
70
- options,
71
- false,
72
- );
101
+ return this.invoke(agent, 'prompt', this.buildPromptCommand(agent, promptOptions, options), options, false);
102
+ }
103
+
104
+ /** Translate a Claude-style slash command and run it as a prompt command. */
105
+ runSlashCommand(
106
+ agent: AgentName,
107
+ input: string,
108
+ promptOptions: PromptOptions,
109
+ options: AgentRunOptions = {},
110
+ ): Promise<AgentRunResult> {
111
+ return this.runPromptCommand(agent, { ...promptOptions, input: translateSlashCommand(agent, input) }, options);
112
+ }
113
+
114
+ /** Build an agent prompt command without executing it. */
115
+ buildPromptCommand(agent: AgentName, promptOptions: PromptOptions, options: AgentRunOptions = {}): ShimCommand {
116
+ return getAgentShim(agent).getPromptCommand(this.withIdentityPreamble(agent, promptOptions, options));
73
117
  }
74
118
 
75
119
  /** Run an agent authentication command, or return null when unsupported. */
@@ -85,15 +129,33 @@ export class AiRunner {
85
129
  options: AgentRunOptions,
86
130
  forceBuffered: boolean,
87
131
  ): Promise<AgentRunResult> {
132
+ const label = `ai-runner.${agent}.${operation}`;
133
+ this.logger.debug('invoke', { label, command: command.command, args: command.args.join(' ') });
134
+ void this.events?.emit('agent.invoke.start', { agent, operation, label });
88
135
  const result: ProcessResult = await this.processExecutor.run({
89
136
  command: command.command,
90
137
  args: command.args,
91
- label: `ai-runner.${agent}.${operation}`,
138
+ label,
92
139
  rejectOnError: false,
93
140
  forceBuffered,
94
141
  cwd: options.cwd ?? this.defaultCwd,
95
142
  timeout: options.timeout ?? this.defaultTimeout,
96
143
  });
144
+ if (result.exitCode !== 0) {
145
+ this.logger.error('invoke exited non-zero', {
146
+ label,
147
+ exitCode: result.exitCode,
148
+ signal: result.signal,
149
+ });
150
+ }
151
+ void this.events?.emit('agent.invoke.exit', {
152
+ agent,
153
+ operation,
154
+ label,
155
+ exitCode: result.exitCode,
156
+ ...(result.signal !== undefined ? { signal: result.signal } : {}),
157
+ durationMs: result.durationMs,
158
+ });
97
159
  return {
98
160
  exitCode: result.exitCode,
99
161
  stdout: result.stdout,
@@ -109,7 +171,7 @@ export class AiRunner {
109
171
  options: AgentRunOptions,
110
172
  ): PromptOptions {
111
173
  if (!hasIdentityOptions(promptOptions)) return promptOptions;
112
- const workspace = options.cwd ?? this.defaultCwd ?? process.cwd();
174
+ const workspace = options.cwd ?? this.defaultCwd ?? getProcessCwd();
113
175
  const preamble = buildIdentityPreamble({
114
176
  agentId: agent,
115
177
  agentType: agent,
@@ -1,9 +1,8 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
- import { getProcessEnv, NodeFileSystem } from '@gobing-ai/ts-runtime';
1
+ import { getLogger, type Logger } from '@gobing-ai/ts-infra';
2
+ import { getProcessEnv, joinPath, NodeFileSystem } from '@gobing-ai/ts-runtime';
4
3
  import { AgentDetector, type DetectedAgent } from './agent-detector';
5
- import { type AgentName, isAgentName, TIER2_AGENTS } from './agents/shims';
6
- import { AiRunner } from './ai-runner';
4
+ import { type AgentName, DISPLAY_ORDER, isAgentName, TIER2_AGENTS } from './agents/shims';
5
+ import { type AgentRunResult, AiRunner } from './ai-runner';
7
6
 
8
7
  /** Health-check result for one coding agent. */
9
8
  export interface DoctorResult {
@@ -35,9 +34,34 @@ export interface DoctorRunnerOptions {
35
34
  timeout?: number;
36
35
  /** Environment map for file/env auth checks. */
37
36
  env?: Record<string, string | undefined>;
37
+ /** Logger for health-check diagnostics. Defaults to `getLogger('doctor')`. */
38
+ logger?: Logger;
38
39
  }
39
40
 
40
41
  const DEFAULT_TIMEOUT_MS = 5_000;
42
+ const AUTH_PATTERNS: Partial<Record<AgentName, { positive: RegExp; negative: RegExp }>> = {
43
+ claude: {
44
+ positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
45
+ negative:
46
+ /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
47
+ },
48
+ codex: {
49
+ positive: /logged[\s_-]*in|authenticated/i,
50
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
51
+ },
52
+ opencode: {
53
+ positive: /configured|available/i,
54
+ negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
55
+ },
56
+ openclaw: {
57
+ positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
58
+ negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
59
+ },
60
+ pi: {
61
+ positive: /\S/,
62
+ negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
63
+ },
64
+ };
41
65
 
42
66
  /** True when a value is a defined, non-blank string. */
43
67
  function isNonEmpty(value: string | undefined): boolean {
@@ -51,18 +75,33 @@ export class DoctorRunner {
51
75
  private readonly timeout: number;
52
76
  private readonly env: Record<string, string | undefined>;
53
77
  private readonly fs = new NodeFileSystem();
78
+ private readonly logger: Logger;
54
79
 
55
80
  constructor(options: DoctorRunnerOptions = {}) {
56
81
  this.runner = options.runner ?? new AiRunner();
57
82
  this.detector = options.agentDetector ?? new AgentDetector({ runner: this.runner });
58
83
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
59
84
  this.env = options.env ?? getProcessEnv();
85
+ this.logger = options.logger ?? getLogger('doctor');
60
86
  }
61
87
 
62
88
  /** Run a health check on all supported agents. */
63
89
  async runAll(): Promise<DoctorResult[]> {
64
90
  const detected = await this.detector.detectAll();
65
- return await Promise.all(detected.map((agent) => this.buildResult(agent)));
91
+ const byName = new Map(detected.map((agent) => [agent.name, agent]));
92
+ return await Promise.all(
93
+ DISPLAY_ORDER.map((agent) =>
94
+ this.buildResult(
95
+ byName.get(agent) ?? {
96
+ name: agent,
97
+ installed: false,
98
+ version: null,
99
+ channels: [],
100
+ error: `Unknown agent: ${agent}`,
101
+ },
102
+ ),
103
+ ),
104
+ );
66
105
  }
67
106
 
68
107
  /** Run a health check on one agent. */
@@ -72,6 +111,7 @@ export class DoctorRunner {
72
111
 
73
112
  private async buildResult(detected: DetectedAgent): Promise<DoctorResult> {
74
113
  const tier = TIER2_AGENTS.has(detected.name as AgentName) ? 2 : 1;
114
+ this.logger.debug('checking agent', { agent: detected.name, installed: detected.installed, tier });
75
115
  const authenticated =
76
116
  detected.installed && isAgentName(detected.name) ? await this.checkAuth(detected.name) : false;
77
117
  return {
@@ -87,24 +127,45 @@ export class DoctorRunner {
87
127
  }
88
128
 
89
129
  private async checkAuth(agent: AgentName): Promise<boolean> {
90
- // gemini/codex expose no auth-status command; treat a non-empty credential
91
- // file as authenticated. An empty/zero-byte file is a stale-credential
92
- // false positive, so existence alone is insufficient.
93
- if (agent === 'gemini') return this.hasNonEmptyFile(join(homedir(), '.gemini', 'settings.json'));
94
- if (agent === 'codex' && (await this.hasNonEmptyFile(join(homedir(), '.codex', 'auth.json')))) return true;
130
+ const home = this.env.HOME || this.env.USERPROFILE || '';
131
+ if (agent === 'gemini') return this.geminiSettingsContainCredentials(home);
132
+ if (agent === 'codex') return this.checkCodexAuth(home);
95
133
  // pi reads provider keys from the environment; require a non-empty value
96
134
  // rather than mere presence (an empty export is not a usable credential).
97
135
  if (agent === 'pi' && (isNonEmpty(this.env.GOOGLE_API_KEY) || isNonEmpty(this.env.ANTHROPIC_API_KEY)))
98
136
  return true;
99
- const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
100
- if (command === null) return false;
101
- const result = await command;
137
+ return (await this.probeAuthOutput(agent)) === true;
138
+ }
139
+
140
+ private async checkCodexAuth(home: string): Promise<boolean> {
141
+ const probeStatus = await this.probeAuthOutput('codex');
142
+ if (probeStatus !== null) return probeStatus;
102
143
  return (
103
- result.exitCode === 0 &&
104
- !/not authenticated|not logged|unauthenticated/i.test(`${result.stdout}\n${result.stderr}`)
144
+ (await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth.json'))) ||
145
+ (await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth')))
105
146
  );
106
147
  }
107
148
 
149
+ private async geminiSettingsContainCredentials(home: string): Promise<boolean> {
150
+ try {
151
+ return /auth|token|key/i.test(await this.fs.readFile(joinPath(home, '.gemini', 'settings.json')));
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ private async probeAuthOutput(agent: AgentName): Promise<boolean | null> {
158
+ const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
159
+ const patterns = AUTH_PATTERNS[agent];
160
+ if (command === null || patterns === undefined) return null;
161
+ const result: AgentRunResult = await command;
162
+ if (result.exitCode !== 0) return false;
163
+ const output = `${result.stdout}\n${result.stderr}`;
164
+ if (patterns.negative.test(output)) return false;
165
+ if (patterns.positive.test(output)) return true;
166
+ return null;
167
+ }
168
+
108
169
  /** True when the path exists and has a non-zero size. */
109
170
  private async hasNonEmptyFile(path: string): Promise<boolean> {
110
171
  const stat = await this.fs.stat(path);
package/src/events.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { ProcessEvents } from '@gobing-ai/ts-runtime';
2
+
3
+ /** Typed event map for agent-runner observability. All events prefixed `agent.`. */
4
+ export type AgentEvents = {
5
+ /** Emitted immediately before an agent CLI invocation starts. */
6
+ 'agent.invoke.start': (data: { agent: string; operation: string; label: string }) => void;
7
+ /** Emitted after an agent CLI invocation exits. */
8
+ 'agent.invoke.exit': (data: {
9
+ agent: string;
10
+ operation: string;
11
+ label: string;
12
+ exitCode: number | null;
13
+ signal?: string;
14
+ durationMs: number;
15
+ }) => void;
16
+ /** Emitted when a long-running team agent process starts. */
17
+ 'agent.started': (data: { agentId: string; agentType: string; pid: number | null }) => void;
18
+ /** Emitted when a long-running team agent process stops. */
19
+ 'agent.stopped': (data: { agentId: string; exitCode: number | null }) => void;
20
+ /** Emitted when a message is sent to a team agent process. */
21
+ 'agent.message.sent': (data: { agentId: string; ok: boolean }) => void;
22
+ };
23
+
24
+ /** Event map for process-level observability emitted by AiRunner-owned executors. */
25
+ export type AiRunnerProcessEvents = ProcessEvents;