@adhdev/daemon-core 0.9.82-rc.160 → 0.9.82-rc.162

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 (63) hide show
  1. package/dist/cli-adapter-types.d.ts +14 -1
  2. package/dist/commands/mesh-coordinator.d.ts +72 -1
  3. package/dist/config/chat-history.d.ts +2 -0
  4. package/dist/config/mesh-config.d.ts +3 -0
  5. package/dist/index.d.ts +11 -0
  6. package/dist/index.js +4924 -1411
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +4976 -1476
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +30 -0
  11. package/dist/mesh/coordinator-registry.d.ts +35 -1
  12. package/dist/providers/cli-provider-instance.d.ts +1 -1
  13. package/dist/providers/contracts.d.ts +48 -0
  14. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +1 -1
  15. package/dist/providers/native-history/claude-cli-transcript.d.ts +1 -1
  16. package/dist/providers/native-history/codex-cli-transcript.d.ts +1 -1
  17. package/dist/providers/native-history/dispatcher.d.ts +24 -0
  18. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  19. package/dist/providers/native-history/index.d.ts +2 -0
  20. package/dist/providers/spec/adapter.d.ts +56 -0
  21. package/dist/providers/spec/cli-adapter.d.ts +76 -0
  22. package/dist/providers/spec/driver.d.ts +148 -0
  23. package/dist/providers/spec/evaluator.d.ts +47 -0
  24. package/dist/providers/spec/loader.d.ts +14 -0
  25. package/dist/providers/spec/native-history-executor.d.ts +39 -0
  26. package/dist/providers/spec/route.d.ts +4 -0
  27. package/dist/providers/spec/schema.gen.d.ts +507 -0
  28. package/dist/providers/spec/types.d.ts +211 -0
  29. package/dist/repo-mesh-types.d.ts +33 -1
  30. package/dist/sessions/registry.d.ts +3 -0
  31. package/package.json +2 -1
  32. package/src/cli-adapter-types.ts +15 -1
  33. package/src/commands/chat-commands.ts +150 -12
  34. package/src/commands/cli-manager.ts +11 -0
  35. package/src/commands/mesh-coordinator.ts +235 -1
  36. package/src/commands/router.ts +238 -50
  37. package/src/config/chat-history.ts +11 -3
  38. package/src/config/mesh-config.ts +16 -1
  39. package/src/index.ts +19 -0
  40. package/src/mesh/coordinator-prompt.ts +164 -8
  41. package/src/mesh/coordinator-registry.ts +50 -4
  42. package/src/providers/cli-provider-instance.ts +8 -3
  43. package/src/providers/contracts.ts +53 -0
  44. package/src/providers/native-history/antigravity-cli-transcript.ts +2 -2
  45. package/src/providers/native-history/claude-cli-transcript.ts +1 -1
  46. package/src/providers/native-history/codex-cli-transcript.ts +1 -1
  47. package/src/providers/native-history/dispatcher.ts +227 -0
  48. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  49. package/src/providers/native-history/index.ts +7 -0
  50. package/src/providers/provider-loader.ts +126 -3
  51. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +13 -0
  52. package/src/providers/spec/adapter.ts +168 -0
  53. package/src/providers/spec/cli-adapter.ts +318 -0
  54. package/src/providers/spec/driver.ts +498 -0
  55. package/src/providers/spec/evaluator.ts +268 -0
  56. package/src/providers/spec/loader.ts +130 -0
  57. package/src/providers/spec/native-history-executor.ts +612 -0
  58. package/src/providers/spec/route.ts +51 -0
  59. package/src/providers/spec/schema.gen.ts +507 -0
  60. package/src/providers/spec/schema.json +210 -0
  61. package/src/providers/spec/types.ts +230 -0
  62. package/src/repo-mesh-types.ts +33 -1
  63. package/src/sessions/registry.ts +3 -0
@@ -1,7 +1,13 @@
1
1
  import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
3
  import * as os from 'node:os'
3
4
  import { isAbsolute, join, resolve } from 'node:path'
4
- import type { ProviderModule, MeshCoordinatorMcpConfigFormat } from '../providers/contracts.js'
5
+ import { LOG } from '../logging/logger.js'
6
+ import type {
7
+ MeshCoordinatorMcpConfigFormat,
8
+ MeshCoordinatorSystemPromptInjection,
9
+ ProviderModule,
10
+ } from '../providers/contracts.js'
5
11
 
6
12
  export interface MeshCoordinatorMcpServerLaunch {
7
13
  command: string
@@ -249,3 +255,231 @@ function resolveMcpPort(explicitPort?: number): number | undefined {
249
255
  const parsed = Number(raw)
250
256
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
251
257
  }
258
+
259
+ /**
260
+ * Apply a provider's declared system-prompt injection rule, mutating `cliArgs`
261
+ * and `launchEnv` in place and writing any required workspace context file.
262
+ *
263
+ * Replaces the previous hard-coded `if (cliType === 'claude-cli') ... else if
264
+ * (cliType === 'hermes-cli') ...` branches in router.ts. Adding a new CLI is
265
+ * now provider.v1.json data, not a router edit.
266
+ *
267
+ * Failures are non-fatal: log and continue with whatever was applied so the
268
+ * coordinator session still launches, just without the prompt for that
269
+ * provider. A missing/unknown rule means "skip injection" — safe by default
270
+ * (the previous fallback unconditionally pushed --append-system-prompt onto
271
+ * every non-Claude CLI, which crashed agy on launch).
272
+ */
273
+ export interface CoordinatorInjectionEffect {
274
+ /** Absolute path the daemon wrote a wrapper-blocked file to. Only set for
275
+ * context_file injection. R48 schedules a strip after launch so workers
276
+ * don't see the wrapper on disk; R47's unregister cleanup uses it as a
277
+ * fallback if the timer never fires (process crash, etc). */
278
+ contextFilePath?: string
279
+ }
280
+
281
+ export function applyMeshCoordinatorSystemPromptInjection(
282
+ systemPrompt: string,
283
+ injection: MeshCoordinatorSystemPromptInjection | undefined,
284
+ ctx: { cliArgs: string[]; launchEnv: Record<string, string>; workspace: string; cliType: string },
285
+ ): CoordinatorInjectionEffect {
286
+ if (!systemPrompt || !injection) return {}
287
+ return applyInjectionRule(systemPrompt, injection, ctx)
288
+ }
289
+
290
+ function applyInjectionRule(
291
+ systemPrompt: string,
292
+ injection: MeshCoordinatorSystemPromptInjection,
293
+ ctx: { cliArgs: string[]; launchEnv: Record<string, string>; workspace: string; cliType: string },
294
+ ): CoordinatorInjectionEffect {
295
+ switch (injection.mode) {
296
+ case 'cli_arg': {
297
+ if (!injection.flag) return {}
298
+ ctx.cliArgs.push(injection.flag, systemPrompt)
299
+ return {}
300
+ }
301
+ case 'config_override': {
302
+ if (!injection.flag || !injection.template) return {}
303
+ const rendered = injection.template
304
+ .replace(/\{prompt_json\}/g, JSON.stringify(systemPrompt))
305
+ .replace(/\{prompt\}/g, systemPrompt)
306
+ ctx.cliArgs.push(injection.flag, rendered)
307
+ return {}
308
+ }
309
+ case 'env_var': {
310
+ if (!injection.name) return {}
311
+ ctx.launchEnv[injection.name] = systemPrompt
312
+ return {}
313
+ }
314
+ case 'context_file': {
315
+ if (!injection.path) return {}
316
+ const target = isAbsolute(injection.path)
317
+ ? injection.path
318
+ : join(ctx.workspace, injection.path)
319
+ const wrapper = injection.wrapper && injection.wrapper.includes('{prompt}')
320
+ ? injection.wrapper
321
+ : '{prompt}'
322
+ // Prepend a short managed-by hint inside the wrapper block so a user
323
+ // opening AGENTS.md / GEMINI.md immediately understands the block is
324
+ // auto-regenerated by the daemon on every coordinator launch. The hint
325
+ // sits between the opening sentinel and the prompt body, so the stable
326
+ // sentinels declared in provider.v1.json are untouched and the
327
+ // idempotent replace regex above still matches on relaunches.
328
+ const managedNote =
329
+ '> _Managed by adhdev mesh coordinator — do not hand-edit this block. ' +
330
+ 'Changes inside the sentinels are overwritten on next coordinator launch._'
331
+ const promptWithNote = `${managedNote}\n\n${systemPrompt}`
332
+ const rendered = wrapper.replace(/\{prompt\}/g, promptWithNote)
333
+ // If the wrapper has a stable opening sentinel, treat everything up to
334
+ // the matching closing sentinel as our previously-written block and
335
+ // replace it. Otherwise just append. The marker is the first non-
336
+ // placeholder line of the wrapper; this keeps relaunches idempotent
337
+ // without forcing spec authors to declare an explicit marker.
338
+ const sentinel = wrapper.split('{prompt}')[0].trim()
339
+ try {
340
+ if (existsSync(target)) {
341
+ const existing = readFileSync(target, 'utf-8')
342
+ if (sentinel && existing.includes(sentinel)) {
343
+ const closing = wrapper.split('{prompt}')[1]?.trim()
344
+ const safeOpen = sentinel.replace(/[.+^${}()|[\]\\]/g, '\\$&')
345
+ const safeClose = closing
346
+ ? closing.replace(/[.+^${}()|[\]\\]/g, '\\$&')
347
+ : ''
348
+ const re = closing
349
+ ? new RegExp(`${safeOpen}[\\s\\S]*?${safeClose}`, 'g')
350
+ : new RegExp(`${safeOpen}[\\s\\S]*$`, 'g')
351
+ writeFileSync(target, existing.replace(re, rendered), 'utf-8')
352
+ } else {
353
+ writeFileSync(target, `${existing}\n\n${rendered}`, 'utf-8')
354
+ }
355
+ } else {
356
+ writeFileSync(target, rendered, 'utf-8')
357
+ }
358
+ LOG.info('MeshCoordinator', `Wrote coordinator prompt to ${target} (${ctx.cliType})`)
359
+ return { contextFilePath: target }
360
+ } catch (error: any) {
361
+ LOG.warn('MeshCoordinator', `Could not write ${target}: ${error?.message || error}`)
362
+ return {}
363
+ }
364
+ }
365
+ default:
366
+ // Unknown future mode — skip silently. Adding the new mode is a spec-
367
+ // language extension, not a runtime crash.
368
+ return {}
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Strip the daemon's wrapper block from a context_file we previously wrote.
374
+ *
375
+ * Used in two places:
376
+ * 1. R48 inject-then-remove: ~5s after spawn, after agy/gemini have read
377
+ * the file into their in-memory system-prompt cache. Removing it from
378
+ * disk at that point doesn't affect the running coordinator but keeps
379
+ * worker sessions (or fresh non-coordinator launches) in the same
380
+ * workspace from picking up our wrapper.
381
+ * 2. R47 unregister fallback: if the timer never fires (process crash,
382
+ * kill -9), coordinator-registry.unregisterMeshCoordinator runs the
383
+ * same logic when its entry is dropped.
384
+ *
385
+ * Idempotent: missing file is fine, missing sentinels are fine, returns
386
+ * silently. Leaves user-authored content outside the sentinels intact.
387
+ * Deletes the file outright if our wrapper was the only content.
388
+ */
389
+ export function stripCoordinatorWrapperFile(filePath: string): void {
390
+ const OPEN = '<!-- adhdev-mesh-coordinator-prompt -->'
391
+ const CLOSE = '<!-- /adhdev-mesh-coordinator-prompt -->'
392
+ try {
393
+ if (!existsSync(filePath)) return
394
+ const existing = readFileSync(filePath, 'utf-8')
395
+ const openIdx = existing.indexOf(OPEN)
396
+ if (openIdx < 0) return
397
+ const closeIdx = existing.indexOf(CLOSE, openIdx)
398
+ if (closeIdx < 0) return
399
+ const remaining = (existing.slice(0, openIdx) + existing.slice(closeIdx + CLOSE.length))
400
+ .replace(/^\s*\n+/, '')
401
+ .replace(/\n+\s*$/, '')
402
+ if (!remaining.trim()) {
403
+ try {
404
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
405
+ const fs = require('node:fs')
406
+ fs.unlinkSync(filePath)
407
+ } catch { /* best-effort */ }
408
+ } else {
409
+ writeFileSync(filePath, remaining + '\n', 'utf-8')
410
+ }
411
+ } catch { /* best-effort */ }
412
+ }
413
+
414
+ export interface PtyExecResult {
415
+ exitCode: number | null
416
+ signal: number | null
417
+ output: string
418
+ timedOut: boolean
419
+ }
420
+
421
+ /**
422
+ * Run a one-shot CLI command under a real PTY and collect its output.
423
+ *
424
+ * Some provider mcp-registration commands (`agy mcp add`, future bubbletea
425
+ * TUIs) refuse to run without `/dev/tty`. Daemon-side `execFileSync` runs
426
+ * pipe-only, so those commands fail with errors like
427
+ * `bubbletea: error opening TTY: open /dev/tty: device not configured`
428
+ * and silently skip the registration, leaving the launched session
429
+ * without the adhdev-mesh MCP tools — which is exactly what we saw with
430
+ * agy coordinators.
431
+ *
432
+ * Wrapping the registration through node-pty gives the child a real PTY,
433
+ * so the bubbletea check passes. We close stdin immediately and just
434
+ * collect stdout until the process exits or the timeout fires.
435
+ */
436
+ export async function execUnderPty(
437
+ command: string,
438
+ args: string[],
439
+ options: { cwd?: string; env?: Record<string, string>; timeoutMs?: number } = {},
440
+ ): Promise<PtyExecResult> {
441
+ let ptyLib: any
442
+ try {
443
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
444
+ ptyLib = require('node-pty')
445
+ } catch (error: any) {
446
+ throw new Error(`node-pty is not available: ${error?.message || error}`)
447
+ }
448
+ const env = { ...(options.env ?? (process.env as Record<string, string>)), TERM: 'xterm-256color' }
449
+ const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : 20_000
450
+ return new Promise<PtyExecResult>((resolveResult) => {
451
+ let child: any
452
+ try {
453
+ child = ptyLib.spawn(command, args, {
454
+ name: 'xterm-256color',
455
+ cols: 120,
456
+ rows: 30,
457
+ cwd: options.cwd ?? process.cwd(),
458
+ env,
459
+ })
460
+ } catch (error: any) {
461
+ resolveResult({ exitCode: null, signal: null, output: String(error?.message || error), timedOut: false })
462
+ return
463
+ }
464
+ let buffer = ''
465
+ let settled = false
466
+ const timer = setTimeout(() => {
467
+ if (settled) return
468
+ settled = true
469
+ try { child.kill() } catch { /* ignore */ }
470
+ resolveResult({ exitCode: null, signal: null, output: buffer, timedOut: true })
471
+ }, timeoutMs)
472
+ child.onData((chunk: string) => {
473
+ buffer += chunk
474
+ if (buffer.length > 256 * 1024) {
475
+ buffer = buffer.slice(-128 * 1024)
476
+ }
477
+ })
478
+ child.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
479
+ if (settled) return
480
+ settled = true
481
+ clearTimeout(timer)
482
+ resolveResult({ exitCode, signal: signal ?? null, output: buffer, timedOut: false })
483
+ })
484
+ })
485
+ }
@@ -38,7 +38,7 @@ import { createInteractionId, getRecentDebugTrace, recordDebugTrace } from '../l
38
38
  import { getSessionHostSurfaceKind, partitionSessionHostRecords } from '../session-host/runtime-surface.js';
39
39
  import { createHermesManualMeshCoordinatorSetup, resolveMeshCoordinatorSetup } from './mesh-coordinator.js';
40
40
  import { buildSessionEntries } from '../status/builders.js';
41
- import { registerMeshCoordinator } from '../mesh/coordinator-registry.js';
41
+ import { registerMeshCoordinator, getCoordinatorForSession } from '../mesh/coordinator-registry.js';
42
42
  import { handleMeshForwardEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, queuePendingMeshCoordinatorEvent } from '../mesh/mesh-events.js';
43
43
  import { buildMeshHostRequiredFailure, normalizeMeshDaemonRole, resolveMeshHostStatus } from '../mesh/mesh-host-ownership.js';
44
44
  import { fastForwardMeshNode } from '../mesh/mesh-fast-forward.js';
@@ -4077,6 +4077,116 @@ export class DaemonCommandRouter {
4077
4077
  };
4078
4078
  }
4079
4079
 
4080
+ // Session-info popup data. Aggregates whatever the daemon knows
4081
+ // about a single live session into one envelope so the dashboard
4082
+ // doesn't need to stitch together status + coordinator registry +
4083
+ // session registry on the client. Includes the actual system
4084
+ // prompt that was injected at launch when the session is a mesh
4085
+ // coordinator — that's the "what prompt did the agent see?"
4086
+ // question the info-icon dialog is meant to answer.
4087
+ case 'get_session_info': {
4088
+ const sessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim()
4089
+ : typeof args?.sessionId === 'string' ? args.sessionId.trim() : '';
4090
+ if (!sessionId) return { success: false, error: 'targetSessionId required' };
4091
+ const target = this.deps.sessionRegistry.get(sessionId);
4092
+ if (!target) return { success: false, error: 'Session not found', sessionId };
4093
+ const adapter = this.deps.cliManager.findAdapter(target.providerType, { instanceKey: sessionId })?.adapter;
4094
+ const runtimeMeta = (adapter && typeof (adapter as any).getRuntimeMetadata === 'function')
4095
+ ? (adapter as any).getRuntimeMetadata()
4096
+ : undefined;
4097
+ const coord = getCoordinatorForSession(sessionId);
4098
+ const providerMetaForSession = this.deps.providerLoader.resolve?.(target.providerType) || this.deps.providerLoader.getMeta(target.providerType);
4099
+ return {
4100
+ success: true,
4101
+ session: {
4102
+ sessionId,
4103
+ providerType: target.providerType,
4104
+ providerName: providerMetaForSession?.name,
4105
+ transport: target.transport,
4106
+ workspace: (target as any).workspace,
4107
+ spawnedAtMs: (target as any).spawnedAtMs,
4108
+ providerSessionId: (target as any).providerSessionId,
4109
+ runtimeMetadata: runtimeMeta,
4110
+ },
4111
+ coordinator: coord ? {
4112
+ meshId: coord.meshId,
4113
+ startedAt: coord.startedAt,
4114
+ cliType: coord.cliType,
4115
+ systemPrompt: coord.systemPrompt,
4116
+ extraSystemPrompt: coord.extraSystemPrompt,
4117
+ injection: coord.injection,
4118
+ mcpConfigPath: coord.mcpConfigPath,
4119
+ } : null,
4120
+ };
4121
+ }
4122
+
4123
+ // ── User-level coordinator-prompt files (~/.adhdev/coordinator-prompts/).
4124
+ // These live on this daemon's filesystem and never sync to the
4125
+ // cloud / other daemons — they're per-machine config. The
4126
+ // Settings page in the dashboard reads/writes via these two
4127
+ // commands instead of going through fs from the browser.
4128
+ case 'list_coordinator_prompts': {
4129
+ const fs = await import('node:fs');
4130
+ const path = await import('node:path');
4131
+ const os = await import('node:os');
4132
+ const dir = path.join(os.homedir(), '.adhdev', 'coordinator-prompts');
4133
+ const entries: Record<string, { override: string; append: string }> = {};
4134
+ try {
4135
+ if (fs.existsSync(dir)) {
4136
+ for (const name of fs.readdirSync(dir)) {
4137
+ // Bucket files into <key>.{md|append.md}; ignore others
4138
+ // so a stray README or .DS_Store doesn't show up.
4139
+ const matchOverride = name.match(/^([a-zA-Z0-9_.-]+)\.md$/);
4140
+ const matchAppend = name.match(/^([a-zA-Z0-9_.-]+)\.append\.md$/);
4141
+ // append-pattern wins when both match (file is `.append.md`).
4142
+ const m = matchAppend || matchOverride;
4143
+ if (!m) continue;
4144
+ const isAppend = !!matchAppend;
4145
+ const key = m[1];
4146
+ const full = path.join(dir, name);
4147
+ let content = '';
4148
+ try { content = fs.readFileSync(full, 'utf8'); } catch { /* skip */ }
4149
+ if (!entries[key]) entries[key] = { override: '', append: '' };
4150
+ if (isAppend) entries[key].append = content;
4151
+ else entries[key].override = content;
4152
+ }
4153
+ }
4154
+ } catch (error: any) {
4155
+ return { success: false, error: error?.message || String(error) };
4156
+ }
4157
+ return { success: true, dir, entries };
4158
+ }
4159
+
4160
+ case 'write_coordinator_prompt': {
4161
+ const fs = await import('node:fs');
4162
+ const path = await import('node:path');
4163
+ const os = await import('node:os');
4164
+ const key = typeof args?.key === 'string' ? args.key.trim() : '';
4165
+ const kind = args?.kind === 'append' ? 'append' : 'override';
4166
+ const content = typeof args?.content === 'string' ? args.content : '';
4167
+ // Whitelist key chars so a malicious caller can't write
4168
+ // ../../etc/passwd. Same charset readUserPromptFile accepts.
4169
+ if (!key || !/^[a-zA-Z0-9_.-]+$/.test(key)) {
4170
+ return { success: false, error: 'key must match [a-zA-Z0-9_.-]+' };
4171
+ }
4172
+ const dir = path.join(os.homedir(), '.adhdev', 'coordinator-prompts');
4173
+ const filename = kind === 'append' ? `${key}.append.md` : `${key}.md`;
4174
+ const full = path.join(dir, filename);
4175
+ try {
4176
+ fs.mkdirSync(dir, { recursive: true });
4177
+ if (content.trim()) {
4178
+ fs.writeFileSync(full, content, { encoding: 'utf8', mode: 0o600 });
4179
+ } else if (fs.existsSync(full)) {
4180
+ // Empty content = "reset to default" — delete the file
4181
+ // so the daemon's readUserPromptFile path falls through.
4182
+ fs.unlinkSync(full);
4183
+ }
4184
+ return { success: true, path: full, kind, key };
4185
+ } catch (error: any) {
4186
+ return { success: false, error: error?.message || String(error) };
4187
+ }
4188
+ }
4189
+
4080
4190
  case 'mark_session_seen': {
4081
4191
  const sessionId = args?.sessionId;
4082
4192
  if (!sessionId || typeof sessionId !== 'string') {
@@ -4743,7 +4853,14 @@ export class DaemonCommandRouter {
4743
4853
  delete (policy as any).providerPriority;
4744
4854
  }
4745
4855
  }
4746
- const node = updateNode(meshId, nodeId, { policy: policy as any });
4856
+ const patch: Record<string, unknown> = { policy: policy as any };
4857
+ if (typeof args?.systemPrompt === 'string') {
4858
+ const trimmed = (args.systemPrompt as string).trim();
4859
+ patch.systemPrompt = trimmed || undefined;
4860
+ } else if (args?.systemPrompt === null) {
4861
+ patch.systemPrompt = undefined;
4862
+ }
4863
+ const node = updateNode(meshId, nodeId, patch as any);
4747
4864
  if (!node) return { success: false, error: 'Mesh node not found' };
4748
4865
  return { success: true, node };
4749
4866
  } catch (e: any) {
@@ -5147,6 +5264,19 @@ export class DaemonCommandRouter {
5147
5264
  case 'launch_mesh_coordinator': {
5148
5265
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
5149
5266
  let cliType = typeof args?.cliType === 'string' ? args.cliType.trim() : '';
5267
+ // Optional per-launch system-prompt addition. Dashboard or API
5268
+ // callers (e.g. when spawning a mesh-node-specific coordinator)
5269
+ // can pass extra context that gets appended to the rendered
5270
+ // default prompt under the "## Additional Context" section.
5271
+ // Going through buildCoordinatorSystemPrompt's userInstruction
5272
+ // means user-level override files (~/.adhdev/coordinator-prompts)
5273
+ // and this per-launch addition compose cleanly: an override
5274
+ // wins outright, but if there's no override, the default
5275
+ // prompt + the optional append.md file + this extra context
5276
+ // all stack in declared order.
5277
+ const extraSystemPrompt = typeof args?.extraSystemPrompt === 'string'
5278
+ ? args.extraSystemPrompt.trim()
5279
+ : '';
5150
5280
  if (!meshId) return { success: false, error: 'meshId required' };
5151
5281
 
5152
5282
  try {
@@ -5257,7 +5387,7 @@ export class DaemonCommandRouter {
5257
5387
  // Build coordinator prompt first — fail closed on errors.
5258
5388
  let cliCmdSystemPrompt = '';
5259
5389
  try {
5260
- cliCmdSystemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType });
5390
+ cliCmdSystemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined });
5261
5391
  } catch (error: any) {
5262
5392
  const message = error?.message || String(error);
5263
5393
  LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
@@ -5269,53 +5399,45 @@ export class DaemonCommandRouter {
5269
5399
  };
5270
5400
  }
5271
5401
 
5272
- // Run the provider's MCP registration command.
5402
+ // Run the provider's MCP registration command under a
5403
+ // PTY. Some providers (agy, future bubbletea CLIs)
5404
+ // refuse to run without /dev/tty, so pipe-only
5405
+ // execFileSync silently no-ops the registration and
5406
+ // the coordinator ends up without any mcp tools. With
5407
+ // a real PTY the registration goes through and the
5408
+ // exit code tells us whether it actually persisted.
5409
+ let mcpRegistrationOk = false;
5273
5410
  try {
5274
- const { execFileSync: execCmdSync } = await import('node:child_process');
5411
+ const { execUnderPty } = await import('./mesh-coordinator.js');
5275
5412
  const cmdParts = coordinatorSetup.command.trim().split(/\s+/);
5276
5413
  const [regCmd, ...regArgs] = cmdParts;
5277
- LOG.info('MeshCoordinator', `Running MCP registration: ${coordinatorSetup.command}`);
5278
- execCmdSync(regCmd, regArgs, { stdio: 'pipe', timeout: 15_000 });
5414
+ LOG.info('MeshCoordinator', `Running MCP registration (pty): ${coordinatorSetup.command}`);
5415
+ const ptyResult = await execUnderPty(regCmd, regArgs, { cwd: workspace, timeoutMs: 20_000 });
5416
+ if (ptyResult.timedOut) {
5417
+ LOG.warn('MeshCoordinator', `MCP registration timed out — last output:\n${ptyResult.output.slice(-2000)}`);
5418
+ } else if (ptyResult.exitCode === 0) {
5419
+ mcpRegistrationOk = true;
5420
+ LOG.info('MeshCoordinator', `MCP registration succeeded (exit=0)`);
5421
+ } else {
5422
+ // Non-fatal — many providers return non-zero on duplicate registration.
5423
+ LOG.warn('MeshCoordinator', `MCP registration exit=${ptyResult.exitCode} signal=${ptyResult.signal} — output:\n${ptyResult.output.slice(-2000)}`);
5424
+ }
5279
5425
  } catch (error: any) {
5280
- // Non-fatal server may already be registered (providers return exit 1 on duplicate).
5281
- LOG.warn('MeshCoordinator', `MCP registration command failed (may be pre-registered): ${error?.message || error}`);
5426
+ LOG.warn('MeshCoordinator', `MCP registration command failed: ${error?.message || error}`);
5282
5427
  }
5283
5428
 
5284
- // Inject system prompt using provider-native methods.
5285
- // Codex: -c 'instructions="..."' CLI config override
5286
- // Gemini: write GEMINI.md to workspace (auto-loaded as context)
5429
+ // Inject system prompt declaratively from provider.v1.json.
5287
5430
  const cliCmdArgs: string[] = [];
5288
5431
  const cliCmdEnv: Record<string, string> = {};
5432
+ let cliCmdContextFilePath: string | undefined;
5289
5433
  if (cliCmdSystemPrompt) {
5290
- if (cliType === 'codex-cli') {
5291
- // Codex reads `developer_instructions` from config.toml as system instructions.
5292
- // The -c flag overrides a config key for this session only.
5293
- cliCmdArgs.push('-c', `developer_instructions=${JSON.stringify(cliCmdSystemPrompt)}`);
5294
- } else if (cliType === 'gemini-cli') {
5295
- // Gemini CLI auto-loads GEMINI.md from CWD as project context.
5296
- // Write a temporary GEMINI.md to the workspace before launch.
5297
- try {
5298
- const { writeFileSync: wfs, existsSync: efs, readFileSync: rfs } = await import('node:fs');
5299
- const geminiMdPath = `${workspace}/GEMINI.md`;
5300
- const marker = '<!-- adhdev-mesh-coordinator-prompt -->';
5301
- const markerEnd = '<!-- /adhdev-mesh-coordinator-prompt -->';
5302
- const block = `${marker}\n${cliCmdSystemPrompt}\n${markerEnd}`;
5303
- if (efs(geminiMdPath)) {
5304
- const existing = rfs(geminiMdPath, 'utf-8');
5305
- // Replace existing block or append
5306
- const replaced = existing.replace(
5307
- new RegExp(`${marker}[\\s\\S]*?${markerEnd}`, 'g'),
5308
- block,
5309
- );
5310
- wfs(geminiMdPath, replaced.includes(marker) ? replaced : `${existing}\n\n${block}`);
5311
- } else {
5312
- wfs(geminiMdPath, block);
5313
- }
5314
- LOG.info('MeshCoordinator', `Wrote coordinator prompt to ${workspace}/GEMINI.md`);
5315
- } catch (e: any) {
5316
- LOG.warn('MeshCoordinator', `Could not write GEMINI.md: ${e?.message || e}`);
5317
- }
5318
- }
5434
+ const { applyMeshCoordinatorSystemPromptInjection } = await import('./mesh-coordinator.js');
5435
+ const effect = applyMeshCoordinatorSystemPromptInjection(
5436
+ cliCmdSystemPrompt,
5437
+ providerMeta?.meshCoordinator?.systemPromptInjection,
5438
+ { cliArgs: cliCmdArgs, launchEnv: cliCmdEnv, workspace, cliType },
5439
+ );
5440
+ cliCmdContextFilePath = effect.contextFilePath;
5319
5441
  }
5320
5442
 
5321
5443
  const cliCmdLaunch: any = await this.deps.cliManager.handleCliCommand('launch_cli', {
@@ -5326,6 +5448,22 @@ export class DaemonCommandRouter {
5326
5448
  settings: { meshCoordinatorFor: meshId },
5327
5449
  });
5328
5450
 
5451
+ // R48 inject-then-remove. Spawn was just kicked off above; agy and
5452
+ // gemini-cli read AGENTS.md / GEMINI.md exactly once at startup and
5453
+ // cache it for the rest of the session, so we can safely strip
5454
+ // the wrapper from disk shortly after launch. That keeps any
5455
+ // worker session launched into the same workspace later from
5456
+ // picking up our wrapper block.
5457
+ if (cliCmdLaunch?.success && cliCmdContextFilePath) {
5458
+ const stripPath = cliCmdContextFilePath;
5459
+ setTimeout(() => {
5460
+ void import('./mesh-coordinator.js').then(({ stripCoordinatorWrapperFile }) => {
5461
+ stripCoordinatorWrapperFile(stripPath);
5462
+ LOG.info('MeshCoordinator', `Stripped wrapper from ${stripPath} after launch settle (cli_command)`);
5463
+ }).catch(() => { /* best-effort */ });
5464
+ }, 5000);
5465
+ }
5466
+
5329
5467
  if (!cliCmdLaunch?.success) {
5330
5468
  return { success: false, error: cliCmdLaunch?.error || 'Failed to launch CLI session' };
5331
5469
  }
@@ -5333,7 +5471,23 @@ export class DaemonCommandRouter {
5333
5471
  LOG.info('MeshCoordinator', `Launched ${cliType} coordinator (cli_command) for mesh ${meshId}`);
5334
5472
  const cliCmdSessionId = cliCmdLaunch.sessionId || cliCmdLaunch.id;
5335
5473
  if (cliCmdSessionId) {
5336
- registerMeshCoordinator({ meshId, sessionId: cliCmdSessionId, workspace, startedAt: Date.now() });
5474
+ const cliCmdInjectionDecl = providerMeta?.meshCoordinator?.systemPromptInjection;
5475
+ registerMeshCoordinator({
5476
+ meshId,
5477
+ sessionId: cliCmdSessionId,
5478
+ workspace,
5479
+ startedAt: Date.now(),
5480
+ cliType,
5481
+ systemPrompt: cliCmdSystemPrompt || undefined,
5482
+ extraSystemPrompt: extraSystemPrompt || undefined,
5483
+ injection: cliCmdInjectionDecl ? {
5484
+ mode: cliCmdInjectionDecl.mode,
5485
+ target: 'flag' in cliCmdInjectionDecl ? cliCmdInjectionDecl.flag
5486
+ : 'name' in cliCmdInjectionDecl ? cliCmdInjectionDecl.name
5487
+ : 'path' in cliCmdInjectionDecl ? cliCmdInjectionDecl.path
5488
+ : undefined,
5489
+ } : undefined,
5490
+ });
5337
5491
  }
5338
5492
  try {
5339
5493
  const { appendLedgerEntry } = await import('../mesh/mesh-ledger.js');
@@ -5351,7 +5505,7 @@ export class DaemonCommandRouter {
5351
5505
  cliType,
5352
5506
  workspace,
5353
5507
  sessionId: cliCmdSessionId,
5354
- mcpRegistered: true,
5508
+ mcpRegistered: mcpRegistrationOk,
5355
5509
  };
5356
5510
  }
5357
5511
 
@@ -5372,7 +5526,7 @@ export class DaemonCommandRouter {
5372
5526
  // broken mesh state is visible instead of silently launching with weaker rules.
5373
5527
  let systemPrompt = '';
5374
5528
  try {
5375
- systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType });
5529
+ systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType, userInstruction: extraSystemPrompt || undefined });
5376
5530
  } catch (error: any) {
5377
5531
  const message = error?.message || String(error);
5378
5532
  LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
@@ -5487,12 +5641,15 @@ export class DaemonCommandRouter {
5487
5641
  launchEnv.HERMES_HOME = dirname(mcpConfigPath);
5488
5642
  launchEnv.HERMES_IGNORE_USER_CONFIG = '';
5489
5643
  }
5644
+ let autoImportContextFilePath: string | undefined;
5490
5645
  if (systemPrompt) {
5491
- if (configFormat === 'hermes_config_yaml') {
5492
- launchEnv.HERMES_EPHEMERAL_SYSTEM_PROMPT = systemPrompt;
5493
- } else {
5494
- cliArgs.push('--append-system-prompt', systemPrompt);
5495
- }
5646
+ const { applyMeshCoordinatorSystemPromptInjection } = await import('./mesh-coordinator.js');
5647
+ const effect = applyMeshCoordinatorSystemPromptInjection(
5648
+ systemPrompt,
5649
+ providerMeta?.meshCoordinator?.systemPromptInjection,
5650
+ { cliArgs, launchEnv, workspace, cliType },
5651
+ );
5652
+ autoImportContextFilePath = effect.contextFilePath;
5496
5653
  }
5497
5654
  if (cliType === 'claude-cli') {
5498
5655
  cliArgs.push('--mcp-config', coordinatorSetup.configPath);
@@ -5511,6 +5668,20 @@ export class DaemonCommandRouter {
5511
5668
  }
5512
5669
  });
5513
5670
 
5671
+ // R48 inject-then-remove. See the cli_command branch for context;
5672
+ // same idea: strip the wrapper from disk ~5s after launch so the
5673
+ // user's AGENTS.md / GEMINI.md is untouched the moment any
5674
+ // worker session opens up in the same workspace.
5675
+ if (launchResult?.success && autoImportContextFilePath) {
5676
+ const stripPath = autoImportContextFilePath;
5677
+ setTimeout(() => {
5678
+ void import('./mesh-coordinator.js').then(({ stripCoordinatorWrapperFile }) => {
5679
+ stripCoordinatorWrapperFile(stripPath);
5680
+ LOG.info('MeshCoordinator', `Stripped wrapper from ${stripPath} after launch settle (auto_import)`);
5681
+ }).catch(() => { /* best-effort */ });
5682
+ }, 5000);
5683
+ }
5684
+
5514
5685
  if (!launchResult?.success) {
5515
5686
  return { success: false, error: launchResult?.error || 'Failed to launch CLI session' };
5516
5687
  }
@@ -5518,7 +5689,24 @@ export class DaemonCommandRouter {
5518
5689
  LOG.info('MeshCoordinator', `Launched ${cliType} coordinator for mesh ${meshId} in ${workspace}`);
5519
5690
  const launchSessionId = launchResult.sessionId || launchResult.id;
5520
5691
  if (launchSessionId) {
5521
- registerMeshCoordinator({ meshId, sessionId: launchSessionId, workspace, startedAt: Date.now() });
5692
+ const autoImportInjectionDecl = providerMeta?.meshCoordinator?.systemPromptInjection;
5693
+ registerMeshCoordinator({
5694
+ meshId,
5695
+ sessionId: launchSessionId,
5696
+ workspace,
5697
+ startedAt: Date.now(),
5698
+ cliType,
5699
+ systemPrompt: systemPrompt || undefined,
5700
+ extraSystemPrompt: extraSystemPrompt || undefined,
5701
+ mcpConfigPath,
5702
+ injection: autoImportInjectionDecl ? {
5703
+ mode: autoImportInjectionDecl.mode,
5704
+ target: 'flag' in autoImportInjectionDecl ? autoImportInjectionDecl.flag
5705
+ : 'name' in autoImportInjectionDecl ? autoImportInjectionDecl.name
5706
+ : 'path' in autoImportInjectionDecl ? autoImportInjectionDecl.path
5707
+ : undefined,
5708
+ } : undefined,
5709
+ });
5522
5710
  }
5523
5711
 
5524
5712
  // Record coordinator launch in task ledger