mixdog 0.9.67 → 0.9.69

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 (73) hide show
  1. package/package.json +6 -4
  2. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -6
  3. package/src/runtime/agent/orchestrator/context/collect.mjs +42 -27
  4. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +1 -1
  5. package/src/runtime/agent/orchestrator/providers/gemini.mjs +0 -6
  6. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +161 -0
  7. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +8 -204
  8. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +26 -0
  9. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +25 -13
  10. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +3 -0
  11. package/src/runtime/agent/orchestrator/session/manager.mjs +0 -11
  12. package/src/runtime/agent/orchestrator/session/store/listing.mjs +613 -0
  13. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -0
  14. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +10 -2
  15. package/src/runtime/agent/orchestrator/session/store.mjs +9 -575
  16. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +41 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +154 -0
  18. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +78 -18
  19. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +9 -144
  20. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +7 -3
  21. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +24 -8
  23. package/src/runtime/channels/lib/config.mjs +6 -5
  24. package/src/runtime/channels/lib/owned-runtime.mjs +65 -5
  25. package/src/runtime/channels/lib/scheduler.mjs +7 -15
  26. package/src/runtime/channels/lib/tool-dispatch.mjs +6 -1
  27. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +6 -2
  28. package/src/runtime/channels/lib/webhook.mjs +36 -46
  29. package/src/runtime/channels/lib/worker-main.mjs +45 -92
  30. package/src/runtime/shared/automation-attachments.mjs +72 -0
  31. package/src/runtime/shared/automation-workflow.mjs +41 -0
  32. package/src/runtime/shared/schedule-session-run.mjs +10 -1
  33. package/src/runtime/shared/schedules-db.mjs +18 -3
  34. package/src/runtime/shared/webhook-session-run.mjs +57 -0
  35. package/src/runtime/shared/webhooks-db.mjs +19 -3
  36. package/src/session-runtime/channel-config-api.mjs +8 -1
  37. package/src/session-runtime/lifecycle-api.mjs +7 -0
  38. package/src/session-runtime/memory-daemon-probe.mjs +61 -0
  39. package/src/session-runtime/model-capabilities.mjs +6 -4
  40. package/src/session-runtime/notification-bus.mjs +82 -0
  41. package/src/session-runtime/provider-auth-api.mjs +16 -1
  42. package/src/session-runtime/provider-usage.mjs +3 -0
  43. package/src/session-runtime/remote-control.mjs +166 -0
  44. package/src/session-runtime/remote-transcript.mjs +126 -0
  45. package/src/session-runtime/remote-transition-queue.mjs +14 -0
  46. package/src/session-runtime/runtime-core.mjs +184 -660
  47. package/src/session-runtime/runtime-tunables.mjs +57 -0
  48. package/src/session-runtime/self-update.mjs +129 -0
  49. package/src/session-runtime/skills-api.mjs +77 -0
  50. package/src/session-runtime/tool-surface.mjs +83 -0
  51. package/src/session-runtime/workflow-agents-api.mjs +206 -3
  52. package/src/session-runtime/workflow.mjs +84 -17
  53. package/src/standalone/agent-tool/worker-index.mjs +287 -0
  54. package/src/standalone/agent-tool.mjs +22 -346
  55. package/src/standalone/agent-watchdog-registry.mjs +101 -0
  56. package/src/standalone/channel-admin.mjs +36 -1
  57. package/src/standalone/channel-daemon-client.mjs +5 -1
  58. package/src/standalone/channel-daemon-transport.mjs +112 -20
  59. package/src/standalone/channel-daemon.mjs +6 -4
  60. package/src/standalone/channel-worker.mjs +7 -13
  61. package/src/tui/App.jsx +2 -32
  62. package/src/tui/app/channel-pickers.mjs +2 -143
  63. package/src/tui/app/slash-commands.mjs +1 -3
  64. package/src/tui/app/slash-dispatch.mjs +3 -3
  65. package/src/tui/components/StatusLine.jsx +6 -5
  66. package/src/tui/dist/index.mjs +162 -259
  67. package/src/tui/engine/labels.mjs +0 -8
  68. package/src/tui/engine/session-api-ext.mjs +45 -10
  69. package/src/tui/engine/turn-watchdog.mjs +92 -0
  70. package/src/tui/engine/turn.mjs +18 -70
  71. package/src/tui/engine.mjs +14 -1
  72. package/src/workflows/default/WORKFLOW.md +1 -1
  73. package/src/workflows/solo/WORKFLOW.md +1 -1
@@ -10,6 +10,8 @@ import { loadConfig } from '../agent/orchestrator/config.mjs';
10
10
  import { createSession } from '../agent/orchestrator/session/manager/session-lifecycle.mjs';
11
11
  import { askSession } from '../agent/orchestrator/session/manager/ask-session.mjs';
12
12
  import { parseScheduleModelRef } from './schedule-model-ref.mjs';
13
+ import { automationWorkflowOpts } from './automation-workflow.mjs';
14
+ import { automationPromptContent } from './automation-attachments.mjs';
13
15
 
14
16
  /** Resolve schedule.model into a {provider,model,effort?,fast?} route. */
15
17
  function scheduleRouteFromModelRef(modelRef, config = null) {
@@ -43,6 +45,9 @@ export async function runScheduleSession(schedule, { config = null, prompt: prom
43
45
  }
44
46
  const route = scheduleRouteFromModelRef(schedule.model, config);
45
47
  const cwd = schedule.cwd ? String(schedule.cwd) : null;
48
+ // Every fire is a fresh New task (user decision): one prompt, the
49
+ // schedule's model/workflow/project, a brand-new session in the sidebar
50
+ // Automations section (newest per name wins there).
46
51
  const session = createSession({
47
52
  provider: route.provider,
48
53
  model: route.model,
@@ -51,13 +56,17 @@ export async function runScheduleSession(schedule, { config = null, prompt: prom
51
56
  owner: 'user',
52
57
  sourceType: 'schedule',
53
58
  sourceName: schedule.name,
59
+ sourceDelivery: schedule.delivery || null,
54
60
  ...(cwd ? { cwd } : {}),
55
61
  desktopSession: cwd
56
62
  ? { classification: 'project', projectPath: cwd }
57
63
  : { classification: 'task', projectPath: null },
64
+ ...automationWorkflowOpts(schedule.workflow),
58
65
  });
59
66
  // The user message is the schedule's instructions verbatim: the desktop
60
67
  // title/preview derive from it, so no "[Scheduled task: …]" header noise.
61
- const result = await askSession(session.id, prompt, null, null, cwd || undefined);
68
+ // Stored attachments ride along as composer-style content parts.
69
+ const content = automationPromptContent(prompt, schedule.attachments);
70
+ const result = await askSession(session.id, content, null, null, cwd || undefined);
62
71
  return { sessionId: session.id, result: String(result?.content || '') };
63
72
  }
@@ -33,6 +33,9 @@ CREATE TABLE IF NOT EXISTS scheduler.schedules (
33
33
  channel_id text,
34
34
  model text,
35
35
  cwd text,
36
+ workflow text,
37
+ attachments jsonb,
38
+ delivery text,
36
39
  prompt text NOT NULL,
37
40
  enabled boolean NOT NULL DEFAULT true,
38
41
  status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','done')),
@@ -45,6 +48,9 @@ CREATE TABLE IF NOT EXISTS scheduler.schedules (
45
48
  CONSTRAINT schedules_when_xor CHECK ((when_at IS NOT NULL) <> (when_cron IS NOT NULL))
46
49
  );
47
50
  ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS cwd text;
51
+ ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS workflow text;
52
+ ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS attachments jsonb;
53
+ ALTER TABLE scheduler.schedules ADD COLUMN IF NOT EXISTS delivery text;
48
54
  `;
49
55
 
50
56
  async function getDb(dataDir = resolvePluginData()) {
@@ -82,6 +88,9 @@ function rowToDef(row) {
82
88
  channelId: row.channel_id,
83
89
  model: row.model,
84
90
  cwd: row.cwd,
91
+ workflow: row.workflow,
92
+ attachments: row.attachments || null,
93
+ delivery: row.delivery || null,
85
94
  prompt: row.prompt,
86
95
  enabled: row.enabled,
87
96
  status: row.status,
@@ -94,7 +103,7 @@ function rowToDef(row) {
94
103
  };
95
104
  }
96
105
 
97
- const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
106
+ const COLS = 'name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, workflow, attachments, delivery, prompt, enabled, status, last_fired_at, next_fire_at, deferred_until, skipped_until, created_at, updated_at';
98
107
 
99
108
  // ---------------------------------------------------------------------------
100
109
  // Public API
@@ -130,6 +139,9 @@ export async function upsertSchedule(def, { dataDir } = {}) {
130
139
  def.channelId ?? null,
131
140
  def.model ?? null,
132
141
  def.cwd ?? null,
142
+ def.workflow ?? null,
143
+ def.attachments ? JSON.stringify(def.attachments) : null,
144
+ def.delivery ?? null,
133
145
  def.prompt,
134
146
  def.enabled ?? true,
135
147
  def.status ?? 'active',
@@ -137,8 +149,8 @@ export async function upsertSchedule(def, { dataDir } = {}) {
137
149
  ];
138
150
  const { rows } = await db.query(
139
151
  `INSERT INTO scheduler.schedules
140
- (name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, prompt, enabled, status, next_fire_at)
141
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
152
+ (name, description, when_at, when_cron, timezone, target, channel_id, model, cwd, workflow, attachments, delivery, prompt, enabled, status, next_fire_at)
153
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
142
154
  ON CONFLICT (name) DO UPDATE SET
143
155
  description = EXCLUDED.description,
144
156
  when_at = EXCLUDED.when_at,
@@ -148,6 +160,9 @@ export async function upsertSchedule(def, { dataDir } = {}) {
148
160
  channel_id = EXCLUDED.channel_id,
149
161
  model = EXCLUDED.model,
150
162
  cwd = EXCLUDED.cwd,
163
+ workflow = EXCLUDED.workflow,
164
+ attachments = EXCLUDED.attachments,
165
+ delivery = EXCLUDED.delivery,
151
166
  prompt = EXCLUDED.prompt,
152
167
  enabled = EXCLUDED.enabled,
153
168
  next_fire_at = EXCLUDED.next_fire_at,
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Run an inbound webhook as a VISIBLE Mixdog session (schedules parity, user
3
+ * decision): no Lead-context injection, no channel forward — every fire is a
4
+ * fresh New task with the endpoint's prompt/model/workflow/project, owned by
5
+ * 'user' + sourceType 'webhook' (sidebar Automations section, newest per
6
+ * name). Invoked from the channels worker's webhook dispatch.
7
+ */
8
+ import { loadConfig } from '../agent/orchestrator/config.mjs';
9
+ import { createSession } from '../agent/orchestrator/session/manager/session-lifecycle.mjs';
10
+ import { askSession } from '../agent/orchestrator/session/manager/ask-session.mjs';
11
+ import { parseScheduleModelRef } from './schedule-model-ref.mjs';
12
+ import { automationWorkflowOpts } from './automation-workflow.mjs';
13
+ import { automationPromptContent } from './automation-attachments.mjs';
14
+
15
+ /** Endpoint model ref wins; the maintenance.webhook route is the fallback. */
16
+ function webhookRoute(modelRef) {
17
+ if (modelRef) {
18
+ const ref = parseScheduleModelRef(modelRef);
19
+ if (ref && typeof ref === 'object') return ref;
20
+ }
21
+ const cfg = loadConfig({ secrets: false });
22
+ const maintenance = cfg?.maintenance?.webhook;
23
+ if (maintenance?.provider && maintenance?.model) {
24
+ return { provider: maintenance.provider, model: maintenance.model };
25
+ }
26
+ throw new Error('webhook run has no model: set one on the endpoint or configure maintenance.webhook');
27
+ }
28
+
29
+ export async function runWebhookSession({ name, model = null, prompt, cwd = null, workflow = null, attachments = null, delivery = null }) {
30
+ const endpoint = String(name || '').trim();
31
+ const body = String(prompt || '').trim();
32
+ if (!endpoint) throw new Error('runWebhookSession: endpoint name required');
33
+ if (!body) throw new Error(`webhook "${endpoint}" has no prompt body`);
34
+ const route = webhookRoute(model);
35
+ const projectCwd = cwd ? String(cwd) : null;
36
+ const session = createSession({
37
+ provider: route.provider,
38
+ model: route.model,
39
+ ...(route.effort ? { effort: route.effort } : {}),
40
+ ...(route.fast === true ? { fast: true } : {}),
41
+ owner: 'user',
42
+ sourceType: 'webhook',
43
+ sourceName: endpoint,
44
+ sourceDelivery: delivery || null,
45
+ ...(projectCwd ? { cwd: projectCwd } : {}),
46
+ desktopSession: projectCwd
47
+ ? { classification: 'project', projectPath: projectCwd }
48
+ : { classification: 'task', projectPath: null },
49
+ ...automationWorkflowOpts(workflow),
50
+ });
51
+ // The user message leads with the endpoint's instructions, so Recent titles
52
+ // read as the user-authored intent rather than payload noise.
53
+ // Stored attachments ride along as composer-style content parts.
54
+ const content = automationPromptContent(body, attachments);
55
+ const result = await askSession(session.id, content, null, null, projectCwd || undefined);
56
+ return { sessionId: session.id, result: String(result?.content || '') };
57
+ }
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS webhooks.endpoints (
40
40
  created_at timestamptz NOT NULL DEFAULT now(),
41
41
  updated_at timestamptz NOT NULL DEFAULT now()
42
42
  );
43
+ ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS cwd text;
44
+ ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS workflow text;
45
+ ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS attachments jsonb;
46
+ ALTER TABLE webhooks.endpoints ADD COLUMN IF NOT EXISTS delivery text;
43
47
  CREATE TABLE IF NOT EXISTS webhooks.deliveries (
44
48
  endpoint text NOT NULL,
45
49
  delivery_id text NOT NULL,
@@ -77,7 +81,7 @@ async function getDb(dataDir = resolvePluginData()) {
77
81
  // Row <-> def mapping
78
82
  // ---------------------------------------------------------------------------
79
83
 
80
- const ENDPOINT_COLS = 'name, description, channel_id, role, model, parser, secret, instructions, enabled, created_at, updated_at';
84
+ const ENDPOINT_COLS = 'name, description, channel_id, role, model, parser, secret, cwd, workflow, attachments, delivery, instructions, enabled, created_at, updated_at';
81
85
 
82
86
  function rowToEndpoint(row) {
83
87
  if (!row) return null;
@@ -88,6 +92,10 @@ function rowToEndpoint(row) {
88
92
  role: row.role,
89
93
  model: row.model,
90
94
  parser: row.parser,
95
+ cwd: row.cwd,
96
+ workflow: row.workflow,
97
+ attachments: row.attachments || null,
98
+ delivery: row.delivery || null,
91
99
  // Never project the plaintext secret through list/load config paths;
92
100
  // callers get a presence flag and must fetch the value via
93
101
  // readEndpointSecret (the single, explicit secret-read path).
@@ -165,13 +173,17 @@ export async function upsertEndpoint(def, { dataDir } = {}) {
165
173
  def.model ?? null,
166
174
  def.parser ?? null,
167
175
  def.secret ?? null,
176
+ def.cwd ?? null,
177
+ def.workflow ?? null,
178
+ def.attachments ? JSON.stringify(def.attachments) : null,
179
+ def.delivery ?? null,
168
180
  def.instructions ?? '',
169
181
  def.enabled ?? true,
170
182
  ];
171
183
  const { rows } = await db.query(
172
184
  `INSERT INTO webhooks.endpoints
173
- (name, description, channel_id, role, model, parser, secret, instructions, enabled)
174
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
185
+ (name, description, channel_id, role, model, parser, secret, cwd, workflow, attachments, delivery, instructions, enabled)
186
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
175
187
  ON CONFLICT (name) DO UPDATE SET
176
188
  description = EXCLUDED.description,
177
189
  channel_id = EXCLUDED.channel_id,
@@ -179,6 +191,10 @@ export async function upsertEndpoint(def, { dataDir } = {}) {
179
191
  model = EXCLUDED.model,
180
192
  parser = EXCLUDED.parser,
181
193
  secret = EXCLUDED.secret,
194
+ cwd = EXCLUDED.cwd,
195
+ workflow = EXCLUDED.workflow,
196
+ attachments = EXCLUDED.attachments,
197
+ delivery = EXCLUDED.delivery,
182
198
  instructions = EXCLUDED.instructions,
183
199
  enabled = EXCLUDED.enabled,
184
200
  updated_at = now()
@@ -17,7 +17,7 @@ import { runScheduleSession } from '../runtime/shared/schedule-session-run.mjs';
17
17
  // API object; the mutating admin helpers are imported directly here and the
18
18
  // runtime injects only the closure-owned callbacks (backend flush, channel
19
19
  // worker handle, soft reload).
20
- export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
20
+ export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon, ensureAutomationRuntime = () => {} }) {
21
21
  return {
22
22
  async getChannelSetup() {
23
23
  // Flush a pending debounced backend switch first so setup readers
@@ -42,6 +42,10 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
42
42
  async saveSchedule(entry) {
43
43
  const result = await saveSchedule(entry);
44
44
  reloadChannelsSoon();
45
+ // First automation created while the app is already running: the boot-
46
+ // time autostart check has passed, so kick the worker start path now
47
+ // (no-op when it is already up).
48
+ ensureAutomationRuntime();
45
49
  return result;
46
50
  },
47
51
  async deleteSchedule(name) {
@@ -52,11 +56,13 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
52
56
  async setScheduleEnabled(name, enabled) {
53
57
  const result = await setScheduleEnabled(name, enabled);
54
58
  reloadChannelsSoon();
59
+ if (enabled !== false) ensureAutomationRuntime();
55
60
  return result;
56
61
  },
57
62
  async saveWebhook(entry) {
58
63
  const result = await saveWebhook(entry);
59
64
  reloadChannelsSoon();
65
+ ensureAutomationRuntime();
60
66
  return result;
61
67
  },
62
68
  async deleteWebhook(name) {
@@ -67,6 +73,7 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
67
73
  async setWebhookEnabled(name, enabled) {
68
74
  const result = await setWebhookEnabled(name, enabled);
69
75
  reloadChannelsSoon();
76
+ if (enabled !== false) ensureAutomationRuntime();
70
77
  return result;
71
78
  },
72
79
  // Read-only secret fetch for the webhook editor; no worker reload.
@@ -69,6 +69,9 @@ export function createLifecycleApi(deps) {
69
69
  // Recent / TUI resume next to lead sessions instead of hiding like
70
70
  // agent dispatches.
71
71
  || sourceType === 'schedule'
72
+ // Webhook fires run as visible sessions too (user decision: no Lead
73
+ // injection — the session row IS the notification).
74
+ || sourceType === 'webhook'
72
75
  || (!sourceType && !sourceName && !isAgentOwner(owner));
73
76
  if (!leadish) return null;
74
77
  const rawPreview = s.preview || '';
@@ -106,6 +109,10 @@ export function createLifecycleApi(deps) {
106
109
  // after a turn had already finished.
107
110
  heartbeatAt: Number(heartbeatMtimes.get(s.id)) || 0,
108
111
  desktopSession: s.desktopSession || null,
112
+ // Automation origin: lets the desktop group schedule/webhook runner
113
+ // sessions under the sidebar Automations section instead of Recent.
114
+ sourceType: sourceType || null,
115
+ sourceName: clean(s.sourceName || '') || null,
109
116
  };
110
117
  }).filter(Boolean);
111
118
  };
@@ -0,0 +1,61 @@
1
+ // Eager memory-daemon boot for remote sessions. The channels worker forwards
2
+ // transcript ingests to the memory HTTP service, so its port must be published
3
+ // (and actually listening) before the worker sends its first ingest — otherwise
4
+ // early channel traffic finds no port and gets buffered. Extracted from
5
+ // runtime-core's startRemote, which now just fires it.
6
+ import * as httpMod from 'node:http';
7
+
8
+ const HEALTH_ATTEMPTS = 30;
9
+ const HEALTH_RETRY_MS = 500;
10
+ const HEALTH_TIMEOUT_MS = 1500;
11
+
12
+ function healthOk(port) {
13
+ return new Promise((resolve) => {
14
+ const request = httpMod.request(
15
+ { hostname: '127.0.0.1', port, path: '/health', timeout: HEALTH_TIMEOUT_MS },
16
+ (response) => {
17
+ let body = '';
18
+ response.on('data', (chunk) => { body += chunk; });
19
+ response.on('end', () => {
20
+ try { resolve(JSON.parse(body)?.status === 'ok'); } catch { resolve(false); }
21
+ });
22
+ },
23
+ );
24
+ request.on('error', () => resolve(false));
25
+ request.on('timeout', () => { request.destroy(); resolve(false); });
26
+ request.end();
27
+ });
28
+ }
29
+
30
+ /** Detached, never throws: resolving the module only hands back a handle, so
31
+ * this awaits start() and then polls /health until the daemon is provably
32
+ * reachable (or the failure is profiled). */
33
+ export function startMemoryDaemonEagerly({ getMemoryModule, bootProfile }) {
34
+ void (async () => {
35
+ try {
36
+ // Yield one tick first: the module resolve + daemon fork below is a long
37
+ // synchronous chain that would otherwise starve Ink's queued render and
38
+ // keypress handling.
39
+ await new Promise((resolve) => setImmediate(resolve));
40
+ const module = await getMemoryModule();
41
+ const started = typeof module?.start === 'function' ? await module.start() : null;
42
+ const port = started?.port;
43
+ if (!port) {
44
+ bootProfile('channels:memory-eager-init-failed', { error: 'no port from start()' });
45
+ return;
46
+ }
47
+ for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt += 1) {
48
+ try {
49
+ if (await healthOk(port)) {
50
+ bootProfile('channels:memory-eager-init-ready', { port });
51
+ return;
52
+ }
53
+ } catch { /* probe failure is retried below */ }
54
+ await new Promise((resolve) => setTimeout(resolve, HEALTH_RETRY_MS));
55
+ }
56
+ bootProfile('channels:memory-eager-init-failed', { error: `health not ok after retries (port ${port})` });
57
+ } catch (error) {
58
+ bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) });
59
+ }
60
+ })();
61
+ }
@@ -103,6 +103,8 @@ export function fastPreferenceFor(config, provider, model) {
103
103
  if (!key) return false;
104
104
  const saved = config?.modelSettings?.[key];
105
105
  if (saved && typeof saved === 'object' && hasOwn(saved, 'fast')) return saved.fast === true;
106
+ // Pre-modelSettings configs kept the preference in a parallel `fastModels`
107
+ // map. Reading it keeps those installs working; nothing writes it anymore.
106
108
  return config?.fastModels?.[key] === true;
107
109
  }
108
110
 
@@ -118,11 +120,11 @@ export function saveModelSettings(cfgMod, route, { fastCapable = true, baseConfi
118
120
  else nextSetting.fast = false;
119
121
  modelSettings[key] = nextSetting;
120
122
 
121
- // Legacy compatibility: keep fastModels true entries for old readers, but
122
- // let modelSettings.fast=false override them in new readers.
123
+ // modelSettings is the single source of truth for the fast preference; the
124
+ // stale mirror in `fastModels` is dropped for the key being written so the
125
+ // two can never disagree.
123
126
  const fastModels = { ...(nextConfig.fastModels || {}) };
124
- if (nextSetting.fast === true) fastModels[key] = true;
125
- else delete fastModels[key];
127
+ delete fastModels[key];
126
128
 
127
129
  const savedConfig = { ...nextConfig, modelSettings, fastModels };
128
130
  cfgMod.saveConfig(savedConfig);
@@ -0,0 +1,82 @@
1
+ // Runtime notification fan-out. A notification reaches listeners (TUI/API) and,
2
+ // for terminal tool completions, may also be mirrored into the session's
3
+ // pending queue so the model sees it next turn. The delivered registry keeps
4
+ // those two paths from double-injecting the same completion.
5
+ import { modelVisibleToolCompletionMessage } from '../runtime/shared/tool-execution-contract.mjs';
6
+ import { markCompletionEntry } from '../runtime/agent/orchestrator/session/manager/pending-messages.mjs';
7
+ import {
8
+ isDeliveredCompletion,
9
+ logDuplicateSkip,
10
+ recordDeliveredCompletion,
11
+ } from '../runtime/agent/orchestrator/session/manager/delivered-completions.mjs';
12
+ import { shouldMirrorCompletionToPendingQueue } from './runtime-tool-routing.mjs';
13
+
14
+ export function createNotificationBus({ listeners, mgr }) {
15
+ function emitRuntimeNotification(content, meta = {}) {
16
+ const text = String(content || '').trim();
17
+ if (!text) return { handled: false, modelVisibleDelivered: false };
18
+ const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
19
+ let handled = false;
20
+ for (const listener of [...listeners]) {
21
+ try {
22
+ if (listener(event) === true) handled = true;
23
+ } catch { /* a broken listener never blocks the rest */ }
24
+ }
25
+ // EXPLICIT model-visible ack: only the TUI execution-ui path sets
26
+ // event.modelVisibleDelivered when it enqueues the body into the active
27
+ // loop. A display-only listener returning true is NOT that ack.
28
+ return { handled, modelVisibleDelivered: event.modelVisibleDelivered === true };
29
+ }
30
+
31
+ // Enqueue the model-visible twin unless it was already delivered; returns
32
+ // true when the caller may treat the completion as delivered.
33
+ function enqueueCompletion(callerSessionId, text, meta, path) {
34
+ const visible = modelVisibleToolCompletionMessage(text, meta);
35
+ // Non-completion notifications yield '' here and are never queued.
36
+ if (!visible) return false;
37
+ if (isDeliveredCompletion({ executionId: meta?.execution_id, text: visible })) {
38
+ // Delivered + ACKed on the TUI path: suppress the duplicate but report
39
+ // DELIVERED so the caller stops retrying.
40
+ logDuplicateSkip(path, { executionId: meta?.execution_id, text: visible });
41
+ return true;
42
+ }
43
+ return mgr.enqueuePendingMessage(callerSessionId, markCompletionEntry(visible)) > 0;
44
+ }
45
+
46
+ function notifyFnForSession(callerSessionId) {
47
+ return (text, meta = {}) => {
48
+ const { handled, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
49
+ let enqueued = false;
50
+ // Record the TUI delivery so a racing enqueue in THIS process (background
51
+ // reconcile / fallback / notify) skips instead of double-injecting.
52
+ if (modelVisibleDelivered) {
53
+ try {
54
+ recordDeliveredCompletion({
55
+ executionId: meta?.execution_id,
56
+ text: modelVisibleToolCompletionMessage(text, meta),
57
+ });
58
+ } catch { /* registry is best-effort */ }
59
+ }
60
+ // TUI sessions consume raw envelopes for UI cards, but those are
61
+ // internal-only in pending drain: mirror the model-visible twin unless the
62
+ // TUI explicitly acked it.
63
+ if (shouldMirrorCompletionToPendingQueue({
64
+ callerSessionId,
65
+ modelVisibleDelivered,
66
+ hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
67
+ text,
68
+ meta,
69
+ })) {
70
+ try { enqueued = enqueueCompletion(callerSessionId, text, meta, 'mirror'); } catch { /* best-effort */ }
71
+ }
72
+ // Headless/API listeners may exist without consuming the event: keep the
73
+ // fallback enqueue for an otherwise unhandled completion.
74
+ if (!enqueued && !handled && callerSessionId && typeof mgr.enqueuePendingMessage === 'function') {
75
+ try { enqueued = enqueueCompletion(callerSessionId, text, meta, 'fallback'); } catch { /* best-effort */ }
76
+ }
77
+ return enqueued || handled;
78
+ };
79
+ }
80
+
81
+ return { emitRuntimeNotification, notifyFnForSession };
82
+ }
@@ -24,6 +24,8 @@ export function createProviderAuthApi({
24
24
  displayConfig,
25
25
  reloadFullConfig,
26
26
  awaitKeychainPrewarm,
27
+ isKeychainPrewarmReady = () => true,
28
+ hasProviderSetupCached = () => true,
27
29
  invalidateProviderCaches,
28
30
  warmProviderModelCache,
29
31
  refreshProviderCatalogs,
@@ -56,8 +58,21 @@ export function createProviderAuthApi({
56
58
  return renderProviderStatus(displayConfig());
57
59
  },
58
60
  async getProviderSetup(options = {}) {
59
- await awaitKeychainPrewarm();
60
61
  const force = options?.force === true || options?.refresh === true;
62
+ // An unforced read never blocks: the authoritative setup waits on the OS
63
+ // keychain AND probes local provider ports, which together take seconds on
64
+ // a cold start and used to stall the whole settings sweep behind it. Serve
65
+ // the no-secrets snapshot until the real one is cached — flagged, so the
66
+ // caller shows "checking" instead of a wrong "not connected" — and let the
67
+ // scheduled warmup publish the authoritative result for the next read.
68
+ if (!force && (!isKeychainPrewarmReady() || !hasProviderSetupCached())) {
69
+ const quick = await cachedProviderSetup({ quick: true });
70
+ void Promise.resolve(awaitKeychainPrewarm())
71
+ .then(() => cachedProviderSetup({}))
72
+ .catch(() => {});
73
+ return { ...quick, pendingSecrets: !isKeychainPrewarmReady() };
74
+ }
75
+ await awaitKeychainPrewarm();
61
76
  if (force) reloadFullConfig();
62
77
  return await cachedProviderSetup({ force });
63
78
  },
@@ -118,6 +118,9 @@ export function createProviderUsage({
118
118
  return {
119
119
  refreshStatuslineUsageSnapshot,
120
120
  cachedProviderSetup,
121
+ // True once a secrets-aware setup is cached: callers that must not block
122
+ // (settings hydration) can serve the quick snapshot until then.
123
+ hasProviderSetupCached: () => Boolean(caches.providerSetupCache.setup),
121
124
  getUsageDashboard,
122
125
  };
123
126
  }