@controlflow-ai/daemon 0.1.2 → 0.1.4

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 (62) hide show
  1. package/README.md +54 -6
  2. package/bin/daemon.js +6 -1
  3. package/package.json +3 -1
  4. package/src/agent-avatar.ts +30 -0
  5. package/src/agent-key.ts +28 -0
  6. package/src/agent-permissions.ts +359 -0
  7. package/src/agent-runtime.ts +795 -28
  8. package/src/agent-workspace.ts +183 -0
  9. package/src/app.ts +1970 -79
  10. package/src/args.ts +54 -7
  11. package/src/cli.ts +873 -14
  12. package/src/client.ts +472 -10
  13. package/src/coco.ts +9 -40
  14. package/src/codex.ts +33 -5
  15. package/src/config.ts +28 -4
  16. package/src/console.ts +230 -20
  17. package/src/daemon-client.ts +116 -3
  18. package/src/daemon.ts +937 -99
  19. package/src/db.ts +3128 -122
  20. package/src/delivery-ws.ts +269 -0
  21. package/src/format.ts +4 -1
  22. package/src/lark/cli.ts +3 -3
  23. package/src/lark/event-router.ts +60 -4
  24. package/src/lark/inbound-events.ts +156 -3
  25. package/src/lark/server-integration.ts +659 -111
  26. package/src/lark/ws-daemon.ts +136 -10
  27. package/src/local-api.ts +545 -15
  28. package/src/local-auth.ts +33 -1
  29. package/src/message-attachments.ts +71 -0
  30. package/src/messaging-cli.ts +741 -0
  31. package/src/messaging-status.ts +669 -0
  32. package/src/migrations/024_agents_model.ts +10 -0
  33. package/src/migrations/025_room_archive.ts +44 -0
  34. package/src/migrations/026_project_archive.ts +44 -0
  35. package/src/migrations/027_agent_permission_profiles.ts +16 -0
  36. package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
  37. package/src/migrations/029_held_message_drafts.ts +32 -0
  38. package/src/migrations/030_agent_room_read_state.ts +25 -0
  39. package/src/migrations/031_room_tasks.ts +29 -0
  40. package/src/migrations/032_room_reminders.ts +29 -0
  41. package/src/migrations/033_room_saved_messages.ts +25 -0
  42. package/src/migrations/034_agent_activity_events.ts +27 -0
  43. package/src/migrations/035_agent_avatars.ts +17 -0
  44. package/src/migrations/036_project_agent_defaults.ts +21 -0
  45. package/src/migrations/037_message_attachments.ts +36 -0
  46. package/src/migrations/038_agent_activity_room_scope.ts +64 -0
  47. package/src/migrations/039_message_attachments_path.ts +34 -0
  48. package/src/migrations/040_message_attachments_file_schema.ts +80 -0
  49. package/src/migrations/041_room_system_events.ts +30 -0
  50. package/src/migrations/042_message_attachment_file_kind.ts +52 -0
  51. package/src/migrations/043_room_mode_skill_registry.ts +92 -0
  52. package/src/migrations/044_workflow_runtime.ts +69 -0
  53. package/src/migrations/045_skill_repository_ownership.ts +64 -0
  54. package/src/migrations.ts +69 -1
  55. package/src/neeko.ts +40 -4
  56. package/src/runtime-env.ts +179 -0
  57. package/src/runtime-registry.ts +83 -13
  58. package/src/server.ts +244 -4
  59. package/src/token-file.ts +13 -6
  60. package/src/types.ts +362 -0
  61. package/src/workflow-runtime.ts +275 -0
  62. package/src/web.ts +0 -904
@@ -0,0 +1,92 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export const version = 43;
4
+ export const name = 'room_mode_skill_registry';
5
+
6
+ function hasColumn(db: Database, table: string, column: string): boolean {
7
+ const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
8
+ return rows.some((row) => row.name === column);
9
+ }
10
+
11
+ export function up(db: Database): void {
12
+ if (!hasColumn(db, 'chats', 'mode')) {
13
+ db.exec(`ALTER TABLE chats ADD COLUMN mode TEXT NOT NULL DEFAULT 'standard' CHECK (mode IN ('standard', 'idea_development'))`);
14
+ }
15
+
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS skill_definitions (
18
+ key TEXT PRIMARY KEY,
19
+ name TEXT NOT NULL,
20
+ description TEXT NOT NULL,
21
+ instruction_content TEXT NOT NULL,
22
+ status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
23
+ version TEXT NOT NULL DEFAULT '1',
24
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
25
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
26
+ );
27
+
28
+ CREATE TABLE IF NOT EXISTS skill_bindings (
29
+ id TEXT PRIMARY KEY,
30
+ scope TEXT NOT NULL CHECK (scope IN ('project', 'room', 'agent')),
31
+ scope_id TEXT NOT NULL,
32
+ skill_key TEXT NOT NULL REFERENCES skill_definitions(key) ON DELETE CASCADE,
33
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
34
+ priority INTEGER NOT NULL DEFAULT 100,
35
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
36
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
37
+ UNIQUE(scope, scope_id, skill_key)
38
+ );
39
+
40
+ CREATE INDEX IF NOT EXISTS idx_skill_bindings_lookup
41
+ ON skill_bindings(scope, scope_id, enabled, priority, skill_key);
42
+
43
+ INSERT INTO skill_definitions (
44
+ key, name, description, instruction_content, status, version, created_at, updated_at
45
+ )
46
+ VALUES (
47
+ 'idea_development',
48
+ 'Idea Development',
49
+ 'Develop early ideas through conversation and project .pal documents before implementation handoff.',
50
+ 'When this skill is enabled, treat the room as an Idea Development room. Do not rush vague ideas into implementation tasks. Help the user develop intent through conversation, clarify maturity and technical participation level, and maintain relevant .pal documents for user review. Keep implementation handoff separate until the idea is clear enough. Preserve ordinary rooms as ordinary rooms; this workflow applies only where the skill is enabled.',
51
+ 'active',
52
+ '1',
53
+ datetime('now'),
54
+ datetime('now')
55
+ )
56
+ ON CONFLICT(key) DO UPDATE SET
57
+ name = excluded.name,
58
+ description = excluded.description,
59
+ instruction_content = excluded.instruction_content,
60
+ status = excluded.status,
61
+ version = excluded.version,
62
+ updated_at = datetime('now');
63
+
64
+ DROP VIEW IF EXISTS chat_stats;
65
+ CREATE VIEW chat_stats AS
66
+ SELECT
67
+ c.id,
68
+ c.name,
69
+ c.display_name,
70
+ c.kind,
71
+ c.server_id,
72
+ c.provider,
73
+ c.dm_type,
74
+ c.capabilities_json,
75
+ c.audit_visibility,
76
+ c.status,
77
+ c.mode,
78
+ c.project_id,
79
+ p.name AS project_name,
80
+ p.root_path AS project_root_path,
81
+ p.computer_id AS project_computer_id,
82
+ pc.name AS project_computer_name,
83
+ c.created_at,
84
+ COUNT(m.id) AS message_count,
85
+ MAX(m.created_at) AS last_message_at
86
+ FROM chats c
87
+ LEFT JOIN projects p ON p.id = c.project_id
88
+ LEFT JOIN computers pc ON pc.id = p.computer_id
89
+ LEFT JOIN messages m ON m.chat_id = c.id
90
+ GROUP BY c.id;
91
+ `);
92
+ }
@@ -0,0 +1,69 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export const version = 44;
4
+ export const name = 'workflow_runtime';
5
+
6
+ export function up(db: Database): void {
7
+ db.exec(`
8
+ CREATE TABLE IF NOT EXISTS workflow_runs (
9
+ id TEXT PRIMARY KEY,
10
+ file_path TEXT NOT NULL,
11
+ goal TEXT,
12
+ status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')),
13
+ created_by TEXT,
14
+ final_output_json TEXT,
15
+ error TEXT,
16
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
17
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ completed_at TEXT
19
+ );
20
+
21
+ CREATE TABLE IF NOT EXISTS workflow_nodes (
22
+ id TEXT PRIMARY KEY,
23
+ run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
24
+ parent_id TEXT REFERENCES workflow_nodes(id) ON DELETE SET NULL,
25
+ kind TEXT NOT NULL CHECK (kind IN ('phase', 'agent', 'task', 'message', 'final')),
26
+ title TEXT NOT NULL,
27
+ role TEXT,
28
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'failed')),
29
+ context_json TEXT,
30
+ instruction TEXT,
31
+ capabilities_json TEXT NOT NULL DEFAULT '[]',
32
+ output_contract_json TEXT,
33
+ output_json TEXT,
34
+ evidence_json TEXT,
35
+ task_id TEXT REFERENCES room_tasks(id) ON DELETE SET NULL,
36
+ message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL,
37
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
38
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
39
+ completed_at TEXT
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status
43
+ ON workflow_runs(status, updated_at);
44
+
45
+ CREATE INDEX IF NOT EXISTS idx_workflow_nodes_run_parent
46
+ ON workflow_nodes(run_id, parent_id, created_at);
47
+
48
+ INSERT INTO skill_definitions (
49
+ key, name, description, instruction_content, status, version, created_at, updated_at
50
+ )
51
+ VALUES (
52
+ 'workflow_authoring',
53
+ 'Workflow Authoring',
54
+ 'Author PAL Dynamic Orchestration workflow scripts using controlled runtime primitives.',
55
+ 'Use this skill when a complex PAL task should be decomposed into an observable workflow. Write TypeScript or JavaScript workflow files that call PAL workflow primitives for phases, agent nodes, room messages, tasks, result collection, and finalization. Model agent nodes with role, context, instruction, capabilities, output contract, status, and evidence. Keep project filesystem and shell actions inside agent work rather than direct workflow script operations.',
56
+ 'active',
57
+ '1',
58
+ datetime('now'),
59
+ datetime('now')
60
+ )
61
+ ON CONFLICT(key) DO UPDATE SET
62
+ name = excluded.name,
63
+ description = excluded.description,
64
+ instruction_content = excluded.instruction_content,
65
+ status = excluded.status,
66
+ version = excluded.version,
67
+ updated_at = datetime('now');
68
+ `);
69
+ }
@@ -0,0 +1,64 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export const version = 45;
4
+ export const name = 'skill_repository_ownership';
5
+
6
+ function hasColumn(db: Database, table: string, column: string): boolean {
7
+ const rows = db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
8
+ return rows.some((row) => row.name === column);
9
+ }
10
+
11
+ export function up(db: Database): void {
12
+ if (!hasColumn(db, 'skill_definitions', 'source')) {
13
+ db.exec(`ALTER TABLE skill_definitions ADD COLUMN source TEXT NOT NULL DEFAULT 'system'`);
14
+ }
15
+ if (!hasColumn(db, 'skill_definitions', 'owner_user_id')) {
16
+ db.exec(`ALTER TABLE skill_definitions ADD COLUMN owner_user_id TEXT`);
17
+ }
18
+ if (!hasColumn(db, 'skill_definitions', 'project_id')) {
19
+ db.exec(`ALTER TABLE skill_definitions ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL`);
20
+ }
21
+ if (!hasColumn(db, 'skill_definitions', 'repository_path')) {
22
+ db.exec(`ALTER TABLE skill_definitions ADD COLUMN repository_path TEXT`);
23
+ }
24
+
25
+ db.exec(`
26
+ UPDATE skill_definitions
27
+ SET source = 'system',
28
+ owner_user_id = NULL,
29
+ project_id = NULL
30
+ WHERE key IN ('idea_development', 'workflow_authoring');
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_skill_definitions_repository
33
+ ON skill_definitions(source, owner_user_id, project_id, status, key);
34
+
35
+ INSERT INTO skill_definitions (
36
+ key, name, description, instruction_content, status, version, source, owner_user_id, project_id, repository_path, created_at, updated_at
37
+ )
38
+ VALUES (
39
+ 'handoff_delivery',
40
+ 'Handoff Delivery',
41
+ 'Implement handoff-room requests with real code changes, verification, review loops, and evidence-based reporting.',
42
+ 'Use this skill in PAL handoff implementation rooms. Convert documented intent into scoped implementation work, preserve unrelated worktree changes, avoid mock or superficial completion, run focused verification, use explicit planning/implementation/testing/review/synthesis loops for complex tasks, and report changed files, commands, evidence, risks, and limitations.',
43
+ 'active',
44
+ '1',
45
+ 'system',
46
+ NULL,
47
+ NULL,
48
+ NULL,
49
+ datetime('now'),
50
+ datetime('now')
51
+ )
52
+ ON CONFLICT(key) DO UPDATE SET
53
+ name = excluded.name,
54
+ description = excluded.description,
55
+ instruction_content = excluded.instruction_content,
56
+ status = excluded.status,
57
+ version = excluded.version,
58
+ source = excluded.source,
59
+ owner_user_id = excluded.owner_user_id,
60
+ project_id = excluded.project_id,
61
+ repository_path = excluded.repository_path,
62
+ updated_at = datetime('now');
63
+ `);
64
+ }
package/src/migrations.ts CHANGED
@@ -22,6 +22,28 @@ import * as computerAgentAssignments from './migrations/020_computer_agent_assig
22
22
  import * as providerIdentityBindings from './migrations/021_provider_identity_bindings.js';
23
23
  import * as larkAuthorizedUsers from './migrations/022_lark_authorized_users.js';
24
24
  import * as projects from './migrations/023_projects.js';
25
+ import * as agentsModel from './migrations/024_agents_model.js';
26
+ import * as roomArchive from './migrations/025_room_archive.js';
27
+ import * as projectArchive from './migrations/026_project_archive.js';
28
+ import * as agentPermissionProfiles from './migrations/027_agent_permission_profiles.js';
29
+ import * as larkWebsocketRestartState from './migrations/028_lark_websocket_restart_state.js';
30
+ import * as heldMessageDrafts from './migrations/029_held_message_drafts.js';
31
+ import * as agentRoomReadState from './migrations/030_agent_room_read_state.js';
32
+ import * as roomTasks from './migrations/031_room_tasks.js';
33
+ import * as roomReminders from './migrations/032_room_reminders.js';
34
+ import * as roomSavedMessages from './migrations/033_room_saved_messages.js';
35
+ import * as agentActivityEvents from './migrations/034_agent_activity_events.js';
36
+ import * as agentAvatars from './migrations/035_agent_avatars.js';
37
+ import * as projectAgentDefaults from './migrations/036_project_agent_defaults.js';
38
+ import * as messageAttachments from './migrations/037_message_attachments.js';
39
+ import * as agentActivityRoomScope from './migrations/038_agent_activity_room_scope.js';
40
+ import * as messageAttachmentsPath from './migrations/039_message_attachments_path.js';
41
+ import * as messageAttachmentsFileSchema from './migrations/040_message_attachments_file_schema.js';
42
+ import * as roomSystemEvents from './migrations/041_room_system_events.js';
43
+ import * as messageAttachmentFileKind from './migrations/042_message_attachment_file_kind.js';
44
+ import * as roomModeSkillRegistry from './migrations/043_room_mode_skill_registry.js';
45
+ import * as workflowRuntime from './migrations/044_workflow_runtime.js';
46
+ import * as skillRepositoryOwnership from './migrations/045_skill_repository_ownership.js';
25
47
 
26
48
  interface Migration {
27
49
  version: number;
@@ -29,7 +51,53 @@ interface Migration {
29
51
  up(db: Database): void;
30
52
  }
31
53
 
32
- const migrations: Migration[] = [initial, daemonDeliveries, sessionsRuns, messageIdempotency, artifacts, larkChannelFoundation, agentsA0, b0ChatHistory, b0TranscriptIngestSeq, b0TranscriptShadowExternalIds, b0ChannelConversationAuditOnly, b0CrossConversationInvariant, b10EngInboundRawEvents, agentsRuntime, agentRuntimeSessions, roomParticipants, unifiedRoomDelivery, roomDisplayNames, computerConnections, computerAgentAssignments, providerIdentityBindings, larkAuthorizedUsers, projects].sort((a, b) => a.version - b.version);
54
+ const migrations: Migration[] = [
55
+ initial,
56
+ daemonDeliveries,
57
+ sessionsRuns,
58
+ messageIdempotency,
59
+ artifacts,
60
+ larkChannelFoundation,
61
+ agentsA0,
62
+ b0ChatHistory,
63
+ b0TranscriptIngestSeq,
64
+ b0TranscriptShadowExternalIds,
65
+ b0ChannelConversationAuditOnly,
66
+ b0CrossConversationInvariant,
67
+ b10EngInboundRawEvents,
68
+ agentsRuntime,
69
+ agentRuntimeSessions,
70
+ roomParticipants,
71
+ unifiedRoomDelivery,
72
+ roomDisplayNames,
73
+ computerConnections,
74
+ computerAgentAssignments,
75
+ providerIdentityBindings,
76
+ larkAuthorizedUsers,
77
+ projects,
78
+ agentsModel,
79
+ roomArchive,
80
+ projectArchive,
81
+ agentPermissionProfiles,
82
+ larkWebsocketRestartState,
83
+ heldMessageDrafts,
84
+ agentRoomReadState,
85
+ roomTasks,
86
+ roomReminders,
87
+ roomSavedMessages,
88
+ agentActivityEvents,
89
+ agentAvatars,
90
+ projectAgentDefaults,
91
+ messageAttachments,
92
+ agentActivityRoomScope,
93
+ messageAttachmentsPath,
94
+ messageAttachmentsFileSchema,
95
+ roomSystemEvents,
96
+ messageAttachmentFileKind,
97
+ roomModeSkillRegistry,
98
+ workflowRuntime,
99
+ skillRepositoryOwnership,
100
+ ].sort((a, b) => a.version - b.version);
33
101
 
34
102
  function assertContiguousMigrations(): void {
35
103
  for (let index = 0; index < migrations.length; index += 1) {
package/src/neeko.ts CHANGED
@@ -1,17 +1,53 @@
1
- import { buildPalPrompt, runtimeCwd, type AgentRuntime, type AgentRuntimeRunInput } from './agent-runtime.js';
1
+ import { buildPalPrompt, runtimeLaunchRoot, runtimeCwd, type AgentRuntime, type AgentRuntimeRunInput } from './agent-runtime.js';
2
+ import { writeGeneratedOpenCodeConfig } from './agent-permissions.js';
2
3
  export type { AgentRuntimeRunInput, AgentRuntimeRunResult } from './agent-runtime.js';
3
4
  export { runAgentRuntime } from './agent-runtime.js';
4
5
 
5
- export function makeNeekoRuntime(agentUuid: string): AgentRuntime {
6
+ export function makeNeekoRuntime(agentUuid: string, model?: string | null): AgentRuntime {
6
7
  void agentUuid;
8
+ const selectedModel = model?.trim() || 'opencode/nemotron-3-super-free';
7
9
  return {
8
10
  name: 'neeko',
9
- capabilities: { protocol: 'acp', resume: 'runtime-session-id', busyDeliveryMode: 'queue', supportsMcp: true },
11
+ capabilities: { protocol: 'json-stream', resume: 'runtime-session-id', busyDeliveryMode: 'queue', supportsMcp: false, supportsSteer: false },
10
12
  command: 'neeko',
11
13
  buildPrompt: buildPalPrompt,
12
14
  buildCwd: runtimeCwd,
15
+ buildEnv(input: AgentRuntimeRunInput): Record<string, string> {
16
+ return { OPENCODE_CONFIG: writeGeneratedOpenCodeConfig(input) };
17
+ },
13
18
  buildArgs(input: AgentRuntimeRunInput): string[] {
14
- return ['acp', '--cwd', runtimeCwd(input), ...input.extraArgs];
19
+ const prompt = buildPalPrompt(input);
20
+ const resumeArgs = input.runtimeSessionId ? ['--session', input.runtimeSessionId] : [];
21
+ return [
22
+ 'run',
23
+ '--format', 'json',
24
+ '--model', selectedModel,
25
+ '--dir', runtimeLaunchRoot(input),
26
+ ...resumeArgs,
27
+ ...input.extraArgs,
28
+ prompt,
29
+ ];
30
+ },
31
+ parseOutput({ stdout, stderr, input }) {
32
+ let runtimeSessionId = input.runtimeSessionId ?? null;
33
+ const output: string[] = [];
34
+ for (const line of stdout.split(/\r?\n/)) {
35
+ const trimmed = line.trim();
36
+ if (!trimmed.startsWith('{')) continue;
37
+ try {
38
+ const event = JSON.parse(trimmed) as { sessionID?: unknown; type?: unknown; part?: { type?: unknown; text?: unknown } };
39
+ if (typeof event.sessionID === 'string' && event.sessionID.trim()) runtimeSessionId = event.sessionID;
40
+ if (event.type === 'text' && event.part?.type === 'text' && typeof event.part.text === 'string') {
41
+ output.push(event.part.text);
42
+ }
43
+ } catch {
44
+ output.push(trimmed);
45
+ }
46
+ }
47
+ return {
48
+ output: [output.join(''), stderr.trim()].filter(Boolean).join('\n'),
49
+ runtimeSessionId,
50
+ };
15
51
  },
16
52
  };
17
53
  }
@@ -0,0 +1,179 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export interface RuntimeEnvReport {
7
+ effectiveEnv: NodeJS.ProcessEnv;
8
+ hydrated: string[];
9
+ missing: string[];
10
+ warnings: string[];
11
+ krb5: RuntimeKrb5Report;
12
+ }
13
+
14
+ export interface RuntimeKrb5Report {
15
+ ccname?: string;
16
+ valid: boolean;
17
+ expiresAt?: Date;
18
+ principal?: string;
19
+ error?: string;
20
+ skipped?: boolean;
21
+ }
22
+
23
+ interface ProbeKrb5Options {
24
+ runCommand?: (command: string, args: string[], env: NodeJS.ProcessEnv) => { status: number | null; stdout: string; stderr: string; error?: Error };
25
+ }
26
+
27
+ interface HydrateRuntimeEnvOptions extends ProbeKrb5Options {
28
+ userInfo?: () => { username: string; homedir: string };
29
+ getuid?: () => number | undefined;
30
+ exists?: (path: string) => boolean;
31
+ findCommandDir?: (command: string, env: NodeJS.ProcessEnv) => string | undefined;
32
+ }
33
+
34
+ function defaultRunCommand(command: string, args: string[], env: NodeJS.ProcessEnv): { status: number | null; stdout: string; stderr: string; error?: Error } {
35
+ const result = spawnSync(command, args, { env, encoding: 'utf8' });
36
+ return {
37
+ status: result.status,
38
+ stdout: result.stdout ?? '',
39
+ stderr: result.stderr ?? '',
40
+ error: result.error,
41
+ };
42
+ }
43
+
44
+ function krb5FilePath(ccname: string): string {
45
+ return ccname.replace(/^FILE:/, '');
46
+ }
47
+
48
+ export function ensurePathContains(pathValue: string, entries: Array<string | undefined>): string {
49
+ const seen = new Set<string>();
50
+ const parts: string[] = [];
51
+ for (const part of [...entries, ...pathValue.split(delimiter)]) {
52
+ if (!part || seen.has(part)) continue;
53
+ seen.add(part);
54
+ parts.push(part);
55
+ }
56
+ return parts.join(delimiter);
57
+ }
58
+
59
+ export function findCommandDir(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
60
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
61
+ if (!dir) continue;
62
+ if (existsSync(join(dir, command))) return dir;
63
+ }
64
+ return undefined;
65
+ }
66
+
67
+ function parseKlist(output: string): Pick<RuntimeKrb5Report, 'principal' | 'expiresAt' | 'valid'> {
68
+ const principal = output.match(/^Default principal:\s*(.+)$/m)?.[1]?.trim();
69
+ let expiresAt: Date | undefined;
70
+ for (const line of output.split('\n')) {
71
+ const match = line.match(/^\s*\d{2}\/\d{2}\/\d{4}\s+\d{2}:\d{2}:\d{2}\s+(\d{2}\/\d{2}\/\d{4}\s+\d{2}:\d{2}:\d{2})\s+/);
72
+ if (!match) continue;
73
+ const parsed = new Date(match[1]!);
74
+ if (!Number.isNaN(parsed.getTime()) && (!expiresAt || parsed > expiresAt)) expiresAt = parsed;
75
+ }
76
+ return { principal, expiresAt, valid: Boolean(expiresAt && expiresAt.getTime() > Date.now()) };
77
+ }
78
+
79
+ function krb5CheckDisabled(): boolean {
80
+ return /^(1|true|yes|on)$/i.test(process.env.PAL_DAEMON_DISABLE_KRB5_CHECK ?? '');
81
+ }
82
+
83
+ export function probeKrb5(ccname: string | undefined, options: ProbeKrb5Options = {}): RuntimeKrb5Report {
84
+ if (!ccname) return { valid: false };
85
+ if (krb5CheckDisabled()) {
86
+ return { ccname, valid: false, skipped: true, error: 'disabled' };
87
+ }
88
+ const runCommand = options.runCommand ?? defaultRunCommand;
89
+ const result = runCommand('klist', ['-c', ccname], process.env);
90
+ if (result.error) return { ccname, valid: false, error: result.error.message };
91
+ if (result.status !== 0) return { ccname, valid: false, error: (result.stderr || result.stdout).trim() || `klist exited ${result.status}` };
92
+ return { ccname, ...parseKlist(result.stdout) };
93
+ }
94
+
95
+ export function hydrateRuntimeEnv(base: NodeJS.ProcessEnv = process.env, options: HydrateRuntimeEnvOptions = {}): RuntimeEnvReport {
96
+ const env: NodeJS.ProcessEnv = { ...base };
97
+ const hydrated: string[] = [];
98
+ const missing: string[] = [];
99
+ const warnings: string[] = [];
100
+ const currentUser = options.userInfo?.() ?? userInfo();
101
+ const exists = options.exists ?? existsSync;
102
+
103
+ if (!env.HOME) {
104
+ env.HOME = currentUser.homedir || homedir();
105
+ hydrated.push('HOME');
106
+ }
107
+ if (!env.USER) {
108
+ env.USER = currentUser.username;
109
+ hydrated.push('USER');
110
+ }
111
+ if (!env.KRB5CCNAME) {
112
+ const uid = options.getuid?.() ?? process.getuid?.();
113
+ const candidates = [
114
+ env.HOME ? `FILE:${env.HOME}/.krb5cc` : undefined,
115
+ uid === undefined ? undefined : `FILE:/tmp/krb5cc_${uid}`,
116
+ ].filter((candidate): candidate is string => Boolean(candidate));
117
+ const found = candidates.find((candidate) => exists(krb5FilePath(candidate)));
118
+ if (found) {
119
+ env.KRB5CCNAME = found;
120
+ hydrated.push('KRB5CCNAME');
121
+ } else {
122
+ missing.push('KRB5CCNAME');
123
+ }
124
+ }
125
+ if (!env.LANG && !env.LC_ALL) {
126
+ env.LANG = 'C.UTF-8';
127
+ hydrated.push('LANG');
128
+ }
129
+ if (!env.TERM) {
130
+ env.TERM = 'dumb';
131
+ hydrated.push('TERM');
132
+ }
133
+ if (!env.HTTPS_PROXY && !env.https_proxy) warnings.push('HTTPS_PROXY is not set; internal runtime auth may require a proxy');
134
+ if (!env.HTTP_PROXY && !env.http_proxy) warnings.push('HTTP_PROXY is not set; internal runtime auth may require a proxy');
135
+ if (!env.NO_PROXY && !env.no_proxy) warnings.push('NO_PROXY is not set; proxy bypasses may be missing');
136
+
137
+ const passthrough = (env.PAL_RUNTIME_ENV_PASSTHROUGH ?? '').split(',').map((value) => value.trim()).filter(Boolean);
138
+ for (const name of passthrough) {
139
+ if (!env[name] && base[name]) env[name] = base[name];
140
+ }
141
+
142
+ const cocoDir = (options.findCommandDir ?? findCommandDir)('coco', env);
143
+ env.PATH = ensurePathContains(env.PATH ?? '', [
144
+ env.HOME ? `${env.HOME}/.local/bin` : undefined,
145
+ env.HOME ? `${env.HOME}/.bun/bin` : undefined,
146
+ cocoDir,
147
+ ]);
148
+
149
+ const krb5 = probeKrb5(env.KRB5CCNAME, options);
150
+ if (env.KRB5CCNAME && !krb5.valid && !krb5.skipped) warnings.push(`KRB5CCNAME is set but klist did not find a valid ticket${krb5.error ? `: ${krb5.error}` : ''}`);
151
+ return { effectiveEnv: env, hydrated, missing, warnings, krb5 };
152
+ }
153
+
154
+ function formatDuration(ms: number): string {
155
+ const minutes = Math.max(0, Math.round(ms / 60000));
156
+ const hours = Math.floor(minutes / 60);
157
+ const remainingMinutes = minutes % 60;
158
+ return hours > 0 ? `${hours}h${remainingMinutes}m` : `${remainingMinutes}m`;
159
+ }
160
+
161
+ export function logEnvReport(report: RuntimeEnvReport): void {
162
+ const env = report.effectiveEnv;
163
+ console.log('[daemon] runtime env preflight');
164
+ console.log(` HOME=${env.HOME ?? '<missing>'}${report.hydrated.includes('HOME') ? ' (hydrated)' : ''}`);
165
+ console.log(` USER=${env.USER ?? '<missing>'}${report.hydrated.includes('USER') ? ' (hydrated)' : ''}`);
166
+ console.log(` KRB5CCNAME=${env.KRB5CCNAME ?? '<missing>'}${report.hydrated.includes('KRB5CCNAME') ? ' (hydrated)' : ''}`);
167
+ if (report.krb5.valid && report.krb5.expiresAt) {
168
+ console.log(` klist principal=${report.krb5.principal ?? '-'} expires=${report.krb5.expiresAt.toISOString()} (in ${formatDuration(report.krb5.expiresAt.getTime() - Date.now())})`);
169
+ } else if (report.krb5.skipped) {
170
+ console.log(` klist skipped: ${report.krb5.error ?? 'disabled'}`);
171
+ } else if (report.krb5.ccname) {
172
+ console.log(` klist invalid${report.krb5.error ? ` error=${report.krb5.error}` : ''}`);
173
+ } else {
174
+ console.log(' klist skipped: KRB5CCNAME missing');
175
+ }
176
+ const cocoDir = findCommandDir('coco', env);
177
+ console.log(` PATH includes coco=${cocoDir ? `${join(cocoDir, 'coco')} ok` : 'not found'}`);
178
+ console.log(` hydrated=[${report.hydrated.join(', ')}] missing=[${report.missing.join(', ')}] warnings=[${report.warnings.join('; ')}]`);
179
+ }
@@ -3,23 +3,19 @@ import type { AgentRuntime, AgentRuntimeProtocol } from './agent-runtime.js';
3
3
  const supportedProtocols = new Set<AgentRuntimeProtocol>(['json-stream', 'acp']);
4
4
 
5
5
  const runtimeFactories = {
6
- codex: async (agentUuid: string): Promise<AgentRuntime> => {
6
+ codex: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
7
7
  const { makeCodexRuntime } = await import('./codex.js');
8
- return makeCodexRuntime(agentUuid);
8
+ return makeCodexRuntime(agentUuid, model);
9
9
  },
10
- coco: async (agentUuid: string): Promise<AgentRuntime> => {
10
+ coco: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
11
11
  const { makeCocoRuntime } = await import('./coco.js');
12
- return makeCocoRuntime(agentUuid);
12
+ return makeCocoRuntime(agentUuid, model);
13
13
  },
14
- 'coco-stream-json': async (agentUuid: string): Promise<AgentRuntime> => {
15
- const { makeCocoStreamJsonRuntime } = await import('./coco.js');
16
- return makeCocoStreamJsonRuntime(agentUuid);
17
- },
18
- neeko: async (agentUuid: string): Promise<AgentRuntime> => {
14
+ neeko: async (agentUuid: string, model: string | null): Promise<AgentRuntime> => {
19
15
  const { makeNeekoRuntime } = await import('./neeko.js');
20
- return makeNeekoRuntime(agentUuid);
16
+ return makeNeekoRuntime(agentUuid, model);
21
17
  },
22
- } satisfies Record<string, (agentUuid: string) => Promise<AgentRuntime>>;
18
+ } satisfies Record<string, (agentUuid: string, model: string | null) => Promise<AgentRuntime>>;
23
19
 
24
20
  export type RuntimeName = keyof typeof runtimeFactories;
25
21
 
@@ -27,13 +23,87 @@ export function knownRuntimeNames(): RuntimeName[] {
27
23
  return Object.keys(runtimeFactories) as RuntimeName[];
28
24
  }
29
25
 
30
- export async function resolveRuntimeDriver(runtimeName: string, agentUuid: string): Promise<AgentRuntime> {
26
+ export interface RuntimeModelLookup {
27
+ models: string[];
28
+ error: string | null;
29
+ }
30
+
31
+ function modelCommand(runtimeName: string): { command: string; args: string[] } {
32
+ if (runtimeName === 'codex') return { command: 'codex', args: ['debug', 'models'] };
33
+ if (runtimeName === 'coco') return { command: 'coco', args: ['models'] };
34
+ if (runtimeName === 'neeko') return { command: 'neeko', args: ['models'] };
35
+ throw new Error(`Unsupported runtime '${runtimeName}'. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
36
+ }
37
+
38
+ function stripAnsi(value: string): string {
39
+ return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '');
40
+ }
41
+
42
+ function uniqueNonEmpty(values: string[]): string[] {
43
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
44
+ }
45
+
46
+ function parseModelOutput(runtimeName: string, stdout: string): string[] {
47
+ const clean = stripAnsi(stdout).trim();
48
+ if (!clean) return [];
49
+ if (runtimeName === 'codex') {
50
+ const parsed = JSON.parse(clean) as { models?: Array<{ slug?: unknown; id?: unknown; name?: unknown }> };
51
+ return uniqueNonEmpty((parsed.models ?? []).map((model) => String(model.slug ?? model.id ?? model.name ?? '')));
52
+ }
53
+ return uniqueNonEmpty(clean.split(/\r?\n/).filter((line) => !/^\s*(available\s+)?models\s*:?\s*$/i.test(line)));
54
+ }
55
+
56
+ export async function runtimeModelOptions(runtimeName: string): Promise<string[]> {
57
+ const runtime = runtimeName.trim();
58
+ modelCommand(runtime);
59
+ const { command, args } = modelCommand(runtime);
60
+ const proc = Bun.spawn([command, ...args], {
61
+ stdout: 'pipe',
62
+ stderr: 'pipe',
63
+ env: process.env,
64
+ });
65
+ const [stdout, stderr, exitCode] = await Promise.all([
66
+ new Response(proc.stdout).text(),
67
+ new Response(proc.stderr).text(),
68
+ proc.exited,
69
+ ]);
70
+ if (exitCode !== 0) {
71
+ const detail = stderr.trim() || stdout.trim() || `exit ${exitCode}`;
72
+ throw new Error(`Could not load models for runtime '${runtime}': ${detail}`);
73
+ }
74
+ return parseModelOutput(runtime, stdout);
75
+ }
76
+
77
+ export async function lookupRuntimeModels(runtimeName: string): Promise<RuntimeModelLookup> {
78
+ try {
79
+ return { models: await runtimeModelOptions(runtimeName), error: null };
80
+ } catch (error) {
81
+ return { models: [], error: error instanceof Error ? error.message : String(error) };
82
+ }
83
+ }
84
+
85
+ export async function allRuntimeModelOptions(): Promise<Record<RuntimeName, RuntimeModelLookup>> {
86
+ const entries = await Promise.all(knownRuntimeNames().map(async (name) => [name, await lookupRuntimeModels(name)] as const));
87
+ return Object.fromEntries(entries) as Record<RuntimeName, RuntimeModelLookup>;
88
+ }
89
+
90
+ export async function validateRuntimeModel(runtimeName: string, model: string | null | undefined): Promise<string | null> {
91
+ const trimmed = model?.trim() || null;
92
+ if (!trimmed) return null;
93
+ const options = await runtimeModelOptions(runtimeName);
94
+ if (!options.includes(trimmed)) {
95
+ throw new Error(`Unsupported model '${trimmed}' for runtime '${runtimeName}'. Supported models: ${options.join(', ')}.`);
96
+ }
97
+ return trimmed;
98
+ }
99
+
100
+ export async function resolveRuntimeDriver(runtimeName: string, agentUuid: string, model?: string | null): Promise<AgentRuntime> {
31
101
  const factory = runtimeFactories[runtimeName as RuntimeName];
32
102
  if (!factory) {
33
103
  throw new Error(`Unsupported runtime '${runtimeName}'. Supported runtimes: ${knownRuntimeNames().join(', ')}.`);
34
104
  }
35
105
 
36
- const driver = await factory(agentUuid);
106
+ const driver = await factory(agentUuid, model?.trim() || null);
37
107
  if (!supportedProtocols.has(driver.capabilities.protocol)) {
38
108
  throw new Error(`Runtime '${runtimeName}' uses unsupported protocol '${driver.capabilities.protocol}'. Supported protocols: ${Array.from(supportedProtocols).join(', ')}.`);
39
109
  }