@pellux/goodvibes-agent 0.1.36 → 0.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,721 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { createBrowserGoodVibesSdk } from '@pellux/goodvibes-sdk/browser';
4
+ import type { OperatorMethodInput, OperatorMethodOutput } from '@pellux/goodvibes-sdk/contracts';
5
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
+ import type { ShellPathService } from '@/runtime/index.ts';
7
+ import { getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
8
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
9
+ import { SDK_VERSION } from '../version.ts';
10
+ import type { AgentRoutineRecord } from './routine-registry.ts';
11
+
12
+ export const ROUTINE_SCHEDULE_ROUTE = '/api/automation/schedules';
13
+ export const ROUTINE_SCHEDULE_METHOD = 'schedules.create';
14
+
15
+ type ScheduleCreateInput = OperatorMethodInput<'schedules.create'>;
16
+ type ScheduleCreateOutput = OperatorMethodOutput<'schedules.create'>;
17
+
18
+ export interface AgentDaemonConfigReader {
19
+ get(key: string): unknown;
20
+ }
21
+
22
+ export interface AgentDaemonConnection {
23
+ readonly baseUrl: string;
24
+ readonly token: string | null;
25
+ readonly tokenPath: string;
26
+ }
27
+
28
+ export type RoutineScheduleKind = 'cron' | 'every' | 'at';
29
+
30
+ export interface RoutineScheduleSpec {
31
+ readonly kind: RoutineScheduleKind;
32
+ readonly value: string;
33
+ }
34
+
35
+ export interface ParsedRoutineSchedulePromotionArgs {
36
+ readonly routineId: string | null;
37
+ readonly schedule: RoutineScheduleSpec | null;
38
+ readonly name?: string;
39
+ readonly timezone?: string;
40
+ readonly provider?: string;
41
+ readonly model?: string;
42
+ readonly enabled: boolean;
43
+ readonly yes: boolean;
44
+ readonly errors: readonly string[];
45
+ }
46
+
47
+ export interface RoutineSchedulePromotionPreview {
48
+ readonly routineId: string;
49
+ readonly routineName: string;
50
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
51
+ readonly method: typeof ROUTINE_SCHEDULE_METHOD;
52
+ readonly payload: ScheduleCreateInput;
53
+ }
54
+
55
+ export interface RoutineSchedulePromotionSuccess {
56
+ readonly ok: true;
57
+ readonly kind: typeof ROUTINE_SCHEDULE_METHOD;
58
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
59
+ readonly routineId: string;
60
+ readonly routineName: string;
61
+ readonly schedule: ScheduleCreateOutput;
62
+ readonly request: ScheduleCreateInput;
63
+ }
64
+
65
+ export interface RoutineSchedulePromotionFailure {
66
+ readonly ok: false;
67
+ readonly kind:
68
+ | 'confirmation_required'
69
+ | 'auth_required'
70
+ | 'daemon_unavailable'
71
+ | 'version_mismatch'
72
+ | 'daemon_route_unavailable'
73
+ | 'daemon_error';
74
+ readonly error: string;
75
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
76
+ readonly baseUrl?: string;
77
+ readonly daemonVersion?: string;
78
+ readonly expectedSdkVersion?: string;
79
+ }
80
+
81
+ export type RoutineSchedulePromotionResult =
82
+ | RoutineSchedulePromotionSuccess
83
+ | RoutineSchedulePromotionFailure;
84
+
85
+ export interface RoutineScheduleReceipt {
86
+ readonly id: string;
87
+ readonly createdAt: string;
88
+ readonly routineId: string;
89
+ readonly routineName: string;
90
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
91
+ readonly method: typeof ROUTINE_SCHEDULE_METHOD;
92
+ readonly status: 'created' | 'failed';
93
+ readonly daemonBaseUrl: string;
94
+ readonly scheduleId?: string;
95
+ readonly scheduleStatus?: string;
96
+ readonly scheduleName: string;
97
+ readonly scheduleKind: RoutineScheduleKind;
98
+ readonly scheduleValue: string;
99
+ readonly timezone?: string;
100
+ readonly provider?: string;
101
+ readonly model?: string;
102
+ readonly enabled: boolean;
103
+ readonly target: {
104
+ readonly kind?: string;
105
+ readonly surfaceKind?: string;
106
+ readonly preserveThread?: boolean;
107
+ readonly createIfMissing?: boolean;
108
+ };
109
+ readonly deliveryMode?: string;
110
+ readonly failureKind?: RoutineSchedulePromotionFailure['kind'];
111
+ readonly failureError?: string;
112
+ }
113
+
114
+ export interface RoutineScheduleReceiptSnapshot {
115
+ readonly path: string;
116
+ readonly receipts: readonly RoutineScheduleReceipt[];
117
+ }
118
+
119
+ interface RoutineScheduleReceiptStoreFile {
120
+ readonly version: 1;
121
+ readonly receipts: readonly RoutineScheduleReceipt[];
122
+ }
123
+
124
+ const RECEIPT_STORE_VERSION = 1;
125
+
126
+ function isRecord(value: unknown): value is Record<string, unknown> {
127
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
128
+ }
129
+
130
+ function readString(record: Record<string, unknown>, key: string): string | null {
131
+ const value = record[key];
132
+ return typeof value === 'string' ? value : null;
133
+ }
134
+
135
+ function readBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
136
+ const value = record[key];
137
+ return typeof value === 'boolean' ? value : undefined;
138
+ }
139
+
140
+ function nowIso(): string {
141
+ return new Date().toISOString();
142
+ }
143
+
144
+ function optionValue(args: readonly string[], index: number, inlineValue: string | undefined): {
145
+ readonly value: string | undefined;
146
+ readonly nextIndex: number;
147
+ } {
148
+ if (inlineValue !== undefined) return { value: inlineValue, nextIndex: index };
149
+ const next = args[index + 1];
150
+ if (next === undefined || next.startsWith('--')) return { value: undefined, nextIndex: index };
151
+ return { value: next, nextIndex: index + 1 };
152
+ }
153
+
154
+ function normalizeProviderModel(provider: string | undefined, model: string | undefined): {
155
+ readonly provider?: string;
156
+ readonly model?: string;
157
+ } {
158
+ if (!model) return provider ? { provider } : {};
159
+ const normalizedProvider = provider ?? getProviderIdFromModel(model);
160
+ return {
161
+ provider: normalizedProvider,
162
+ model: getModelIdFromProviderModel(model),
163
+ };
164
+ }
165
+
166
+ function readReceipt(value: unknown): RoutineScheduleReceipt | null {
167
+ if (!isRecord(value)) return null;
168
+ const id = readString(value, 'id')?.trim();
169
+ const createdAt = readString(value, 'createdAt')?.trim();
170
+ const routineId = readString(value, 'routineId')?.trim();
171
+ const routineName = readString(value, 'routineName')?.trim();
172
+ const status = value.status === 'created' || value.status === 'failed' ? value.status : null;
173
+ const scheduleKind = value.scheduleKind === 'cron' || value.scheduleKind === 'every' || value.scheduleKind === 'at'
174
+ ? value.scheduleKind
175
+ : null;
176
+ const scheduleValue = readString(value, 'scheduleValue')?.trim();
177
+ const target = isRecord(value.target) ? value.target : {};
178
+ if (!id || !createdAt || !routineId || !routineName || !status || !scheduleKind || !scheduleValue) return null;
179
+ return {
180
+ id,
181
+ createdAt,
182
+ routineId,
183
+ routineName,
184
+ route: ROUTINE_SCHEDULE_ROUTE,
185
+ method: ROUTINE_SCHEDULE_METHOD,
186
+ status,
187
+ daemonBaseUrl: readString(value, 'daemonBaseUrl') ?? '',
188
+ scheduleId: readString(value, 'scheduleId') ?? undefined,
189
+ scheduleStatus: readString(value, 'scheduleStatus') ?? undefined,
190
+ scheduleName: readString(value, 'scheduleName') ?? routineName,
191
+ scheduleKind,
192
+ scheduleValue,
193
+ timezone: readString(value, 'timezone') ?? undefined,
194
+ provider: readString(value, 'provider') ?? undefined,
195
+ model: readString(value, 'model') ?? undefined,
196
+ enabled: value.enabled !== false,
197
+ target: {
198
+ kind: readString(target, 'kind') ?? undefined,
199
+ surfaceKind: readString(target, 'surfaceKind') ?? undefined,
200
+ preserveThread: readBoolean(target, 'preserveThread'),
201
+ createIfMissing: readBoolean(target, 'createIfMissing'),
202
+ },
203
+ deliveryMode: readString(value, 'deliveryMode') ?? undefined,
204
+ failureKind: value.failureKind === 'confirmation_required'
205
+ || value.failureKind === 'auth_required'
206
+ || value.failureKind === 'daemon_unavailable'
207
+ || value.failureKind === 'version_mismatch'
208
+ || value.failureKind === 'daemon_route_unavailable'
209
+ || value.failureKind === 'daemon_error'
210
+ ? value.failureKind
211
+ : undefined,
212
+ failureError: readString(value, 'failureError') ?? undefined,
213
+ };
214
+ }
215
+
216
+ function parseReceiptStore(raw: string): RoutineScheduleReceiptStoreFile {
217
+ const parsed: unknown = JSON.parse(raw);
218
+ if (!isRecord(parsed)) return { version: RECEIPT_STORE_VERSION, receipts: [] };
219
+ return {
220
+ version: RECEIPT_STORE_VERSION,
221
+ receipts: Array.isArray(parsed.receipts)
222
+ ? parsed.receipts.map(readReceipt).filter((receipt): receipt is RoutineScheduleReceipt => receipt !== null)
223
+ : [],
224
+ };
225
+ }
226
+
227
+ function formatReceiptStore(store: RoutineScheduleReceiptStoreFile): string {
228
+ return `${JSON.stringify(store, null, 2)}\n`;
229
+ }
230
+
231
+ function receiptId(createdAt: string, routineId: string, existing: readonly RoutineScheduleReceipt[]): string {
232
+ const dayStamp = createdAt.slice(0, 10).replace(/-/g, '');
233
+ const base = `routine-schedule-${routineId}-${dayStamp}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
234
+ const ids = new Set(existing.map((receipt) => receipt.id));
235
+ if (!ids.has(base)) return base;
236
+ for (let index = 2; index < 1000; index += 1) {
237
+ const candidate = `${base}-${index}`;
238
+ if (!ids.has(candidate)) return candidate;
239
+ }
240
+ return `${base}-${existing.length + 1}`;
241
+ }
242
+
243
+ function scheduleValue(payload: ScheduleCreateInput): string {
244
+ if (payload.kind === 'cron') return String(payload.cron ?? '');
245
+ if (payload.kind === 'every') return String(payload.every ?? '');
246
+ return String(payload.at ?? '');
247
+ }
248
+
249
+ function scheduleKind(payload: ScheduleCreateInput): RoutineScheduleKind {
250
+ if (payload.kind === 'cron' || payload.kind === 'every' || payload.kind === 'at') return payload.kind;
251
+ throw new Error('Routine schedule payload is missing a schedule kind.');
252
+ }
253
+
254
+ function targetSummary(payload: ScheduleCreateInput): RoutineScheduleReceipt['target'] {
255
+ return isRecord(payload.target)
256
+ ? {
257
+ kind: typeof payload.target.kind === 'string' ? payload.target.kind : undefined,
258
+ surfaceKind: typeof payload.target.surfaceKind === 'string' ? payload.target.surfaceKind : undefined,
259
+ preserveThread: typeof payload.target.preserveThread === 'boolean' ? payload.target.preserveThread : undefined,
260
+ createIfMissing: typeof payload.target.createIfMissing === 'boolean' ? payload.target.createIfMissing : undefined,
261
+ }
262
+ : {};
263
+ }
264
+
265
+ function deliveryMode(payload: ScheduleCreateInput): string | undefined {
266
+ return isRecord(payload.delivery) && typeof payload.delivery.mode === 'string' ? payload.delivery.mode : undefined;
267
+ }
268
+
269
+ function resultScheduleRecord(result: RoutineSchedulePromotionResult): Record<string, unknown> {
270
+ return result.ok && isRecord(result.schedule) ? result.schedule : {};
271
+ }
272
+
273
+ function buildReceipt(
274
+ existing: readonly RoutineScheduleReceipt[],
275
+ connection: AgentDaemonConnection,
276
+ preview: RoutineSchedulePromotionPreview,
277
+ result: RoutineSchedulePromotionResult,
278
+ ): RoutineScheduleReceipt {
279
+ const createdAt = nowIso();
280
+ const kind = scheduleKind(preview.payload);
281
+ const schedule = resultScheduleRecord(result);
282
+ const scheduleId = readString(schedule, 'id') ?? undefined;
283
+ const scheduleStatus = readString(schedule, 'status') ?? (schedule.enabled === false ? 'paused' : schedule.enabled === true ? 'enabled' : undefined);
284
+ return {
285
+ id: receiptId(createdAt, preview.routineId, existing),
286
+ createdAt,
287
+ routineId: preview.routineId,
288
+ routineName: preview.routineName,
289
+ route: ROUTINE_SCHEDULE_ROUTE,
290
+ method: ROUTINE_SCHEDULE_METHOD,
291
+ status: result.ok ? 'created' : 'failed',
292
+ daemonBaseUrl: connection.baseUrl,
293
+ scheduleId,
294
+ scheduleStatus,
295
+ scheduleName: String(preview.payload.name ?? `Agent routine: ${preview.routineName}`),
296
+ scheduleKind: kind,
297
+ scheduleValue: scheduleValue(preview.payload),
298
+ timezone: kind === 'cron' ? preview.payload.timezone : undefined,
299
+ provider: preview.payload.provider,
300
+ model: preview.payload.model,
301
+ enabled: preview.payload.enabled !== false,
302
+ target: targetSummary(preview.payload),
303
+ deliveryMode: deliveryMode(preview.payload),
304
+ failureKind: result.ok ? undefined : result.kind,
305
+ failureError: result.ok ? undefined : result.error,
306
+ };
307
+ }
308
+
309
+ export function routineScheduleReceiptStorePath(shellPaths: ShellPathService): string {
310
+ return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'routines', 'schedule-receipts.json');
311
+ }
312
+
313
+ export class RoutineScheduleReceiptStore {
314
+ public constructor(private readonly storePath: string) {}
315
+
316
+ public static fromShellPaths(shellPaths: ShellPathService): RoutineScheduleReceiptStore {
317
+ return new RoutineScheduleReceiptStore(routineScheduleReceiptStorePath(shellPaths));
318
+ }
319
+
320
+ public snapshot(): RoutineScheduleReceiptSnapshot {
321
+ const store = this.readStore();
322
+ return {
323
+ path: this.storePath,
324
+ receipts: [...store.receipts].sort((left, right) => right.createdAt.localeCompare(left.createdAt)),
325
+ };
326
+ }
327
+
328
+ public get(id: string): RoutineScheduleReceipt | null {
329
+ const normalized = id.trim().toLowerCase();
330
+ if (!normalized) return null;
331
+ return this.snapshot().receipts.find((receipt) => receipt.id.toLowerCase() === normalized) ?? null;
332
+ }
333
+
334
+ public append(connection: AgentDaemonConnection, preview: RoutineSchedulePromotionPreview, result: RoutineSchedulePromotionResult): RoutineScheduleReceipt {
335
+ const store = this.readStore();
336
+ const receipt = buildReceipt(store.receipts, connection, preview, result);
337
+ this.writeStore({ ...store, receipts: [...store.receipts, receipt] });
338
+ return receipt;
339
+ }
340
+
341
+ private readStore(): RoutineScheduleReceiptStoreFile {
342
+ if (!existsSync(this.storePath)) return { version: RECEIPT_STORE_VERSION, receipts: [] };
343
+ try {
344
+ return parseReceiptStore(readFileSync(this.storePath, 'utf-8'));
345
+ } catch (error) {
346
+ throw new Error(`Could not read Agent routine schedule receipt store: ${summarizeError(error)}`);
347
+ }
348
+ }
349
+
350
+ private writeStore(store: RoutineScheduleReceiptStoreFile): void {
351
+ mkdirSync(dirname(this.storePath), { recursive: true });
352
+ const tmpPath = `${this.storePath}.tmp`;
353
+ writeFileSync(tmpPath, formatReceiptStore(store), 'utf-8');
354
+ renameSync(tmpPath, this.storePath);
355
+ }
356
+ }
357
+
358
+ export function parseRoutineSchedulePromotionArgs(args: readonly string[]): ParsedRoutineSchedulePromotionArgs {
359
+ let routineId: string | null = null;
360
+ let schedule: RoutineScheduleSpec | null = null;
361
+ let name: string | undefined;
362
+ let timezone: string | undefined;
363
+ let provider: string | undefined;
364
+ let model: string | undefined;
365
+ let enabled = true;
366
+ let yes = false;
367
+ const errors: string[] = [];
368
+
369
+ for (let index = 0; index < args.length; index += 1) {
370
+ const raw = args[index] ?? '';
371
+ const equals = raw.indexOf('=');
372
+ const optionName = equals >= 0 ? raw.slice(0, equals) : raw;
373
+ const inlineValue = equals >= 0 ? raw.slice(equals + 1) : undefined;
374
+
375
+ if (raw === '--yes') {
376
+ yes = true;
377
+ continue;
378
+ }
379
+ if (raw === '--disabled') {
380
+ enabled = false;
381
+ continue;
382
+ }
383
+ if (optionName === '--name' || optionName === '--timezone' || optionName === '--provider' || optionName === '--model') {
384
+ const consumed = optionValue(args, index, inlineValue);
385
+ index = consumed.nextIndex;
386
+ const value = consumed.value?.trim();
387
+ if (!value) {
388
+ errors.push(`${optionName} requires a value.`);
389
+ continue;
390
+ }
391
+ if (optionName === '--name') name = value;
392
+ if (optionName === '--timezone') timezone = value;
393
+ if (optionName === '--provider') provider = value;
394
+ if (optionName === '--model') model = value;
395
+ continue;
396
+ }
397
+ if (optionName === '--cron' || optionName === '--every' || optionName === '--at') {
398
+ const consumed = optionValue(args, index, inlineValue);
399
+ index = consumed.nextIndex;
400
+ const value = consumed.value?.trim();
401
+ if (!value) {
402
+ errors.push(`${optionName} requires a value.`);
403
+ continue;
404
+ }
405
+ if (schedule) {
406
+ errors.push('Choose exactly one schedule selector: --cron, --every, or --at.');
407
+ continue;
408
+ }
409
+ schedule = {
410
+ kind: optionName === '--cron' ? 'cron' : optionName === '--every' ? 'every' : 'at',
411
+ value,
412
+ };
413
+ continue;
414
+ }
415
+ if (raw.startsWith('--')) {
416
+ errors.push(`Unknown option: ${raw}`);
417
+ continue;
418
+ }
419
+ if (!routineId) {
420
+ routineId = raw;
421
+ continue;
422
+ }
423
+ errors.push(`Unexpected argument: ${raw}`);
424
+ }
425
+
426
+ if (!routineId) errors.push('Routine id or name is required.');
427
+ if (!schedule) errors.push('Schedule is required: use --cron <expr>, --every <interval>, or --at <iso-time>.');
428
+ return { routineId, schedule, name, timezone, provider, model, enabled, yes, errors };
429
+ }
430
+
431
+ export function resolveAgentDaemonConnection(
432
+ configManager: AgentDaemonConfigReader,
433
+ homeDirectory: string,
434
+ ): AgentDaemonConnection {
435
+ const host = String(configManager.get('controlPlane.host') ?? '127.0.0.1');
436
+ const port = Number(configManager.get('controlPlane.port') ?? 3421);
437
+ const tokenPath = join(homeDirectory, '.goodvibes', 'daemon', 'operator-tokens.json');
438
+ if (!existsSync(tokenPath)) return { baseUrl: `http://${host}:${Number.isFinite(port) ? port : 3421}`, token: null, tokenPath };
439
+ try {
440
+ const parsed = JSON.parse(readFileSync(tokenPath, 'utf-8')) as unknown;
441
+ const token = isRecord(parsed) && typeof parsed.token === 'string' ? parsed.token : null;
442
+ return { baseUrl: `http://${host}:${Number.isFinite(port) ? port : 3421}`, token, tokenPath };
443
+ } catch {
444
+ return { baseUrl: `http://${host}:${Number.isFinite(port) ? port : 3421}`, token: null, tokenPath };
445
+ }
446
+ }
447
+
448
+ export function buildRoutineSchedulePrompt(routine: AgentRoutineRecord): string {
449
+ return [
450
+ 'GoodVibes Agent scheduled routine.',
451
+ '',
452
+ `Routine: ${routine.name}`,
453
+ `Routine id: ${routine.id}`,
454
+ `Review state: ${routine.reviewState}`,
455
+ `Tags: ${routine.tags.join(', ') || '(none)'}`,
456
+ `Triggers: ${routine.triggers.join(', ') || '(manual)'}`,
457
+ '',
458
+ 'Operator policy:',
459
+ '- Run this as a serial GoodVibes Agent operator routine.',
460
+ '- Use isolated Agent Knowledge routes only; never use default Knowledge/Wiki or HomeGraph as fallback.',
461
+ '- Do not perform destructive, costly, externally visible, or secret-handling actions without explicit approval.',
462
+ '- Do not request WRFC unless this scheduled routine explicitly delegates build/fix/review work to GoodVibes TUI.',
463
+ '- Summarize what was checked, what changed, and what still needs user review.',
464
+ '',
465
+ 'Routine description:',
466
+ routine.description,
467
+ '',
468
+ 'Routine steps:',
469
+ routine.steps,
470
+ ].join('\n');
471
+ }
472
+
473
+ export function buildRoutineSchedulePayload(
474
+ routine: AgentRoutineRecord,
475
+ parsed: ParsedRoutineSchedulePromotionArgs,
476
+ ): ScheduleCreateInput {
477
+ if (!parsed.schedule) throw new Error('Schedule is required.');
478
+ const modelRoute = normalizeProviderModel(parsed.provider, parsed.model);
479
+ const payload: ScheduleCreateInput = {
480
+ name: parsed.name ?? `Agent routine: ${routine.name}`,
481
+ prompt: buildRoutineSchedulePrompt(routine),
482
+ kind: parsed.schedule.kind,
483
+ enabled: parsed.enabled,
484
+ target: {
485
+ kind: 'main',
486
+ surfaceKind: 'service',
487
+ preserveThread: true,
488
+ createIfMissing: true,
489
+ },
490
+ delivery: {
491
+ mode: 'none',
492
+ targets: [],
493
+ fallbackTargets: [],
494
+ includeSummary: true,
495
+ includeTranscript: false,
496
+ includeLinks: true,
497
+ },
498
+ failure: {
499
+ action: 'retry',
500
+ maxConsecutiveFailures: 3,
501
+ cooldownMs: 3_600_000,
502
+ retryPolicy: {
503
+ maxAttempts: 2,
504
+ delayMs: 60_000,
505
+ strategy: 'exponential',
506
+ maxDelayMs: 900_000,
507
+ jitterMs: 30_000,
508
+ },
509
+ disableAfterFailures: false,
510
+ },
511
+ lightContext: true,
512
+ autoApprove: false,
513
+ allowUnsafeExternalContent: false,
514
+ ...modelRoute,
515
+ };
516
+ if (parsed.schedule.kind === 'cron') {
517
+ return {
518
+ ...payload,
519
+ cron: parsed.schedule.value,
520
+ timezone: parsed.timezone,
521
+ };
522
+ }
523
+ if (parsed.schedule.kind === 'every') {
524
+ return { ...payload, every: parsed.schedule.value };
525
+ }
526
+ return { ...payload, at: parsed.schedule.value };
527
+ }
528
+
529
+ export function buildRoutineSchedulePreview(
530
+ routine: AgentRoutineRecord,
531
+ parsed: ParsedRoutineSchedulePromotionArgs,
532
+ ): RoutineSchedulePromotionPreview {
533
+ return {
534
+ routineId: routine.id,
535
+ routineName: routine.name,
536
+ route: ROUTINE_SCHEDULE_ROUTE,
537
+ method: ROUTINE_SCHEDULE_METHOD,
538
+ payload: buildRoutineSchedulePayload(routine, parsed),
539
+ };
540
+ }
541
+
542
+ async function fetchDaemonStatus(connection: AgentDaemonConnection): Promise<{
543
+ readonly ok: boolean;
544
+ readonly status: number;
545
+ readonly body: unknown;
546
+ }> {
547
+ try {
548
+ const response = await fetch(`${connection.baseUrl}/status`, {
549
+ headers: connection.token ? { authorization: `Bearer ${connection.token}` } : undefined,
550
+ });
551
+ const text = await response.text();
552
+ let body: unknown = text;
553
+ try {
554
+ body = text.trim() ? JSON.parse(text) as unknown : {};
555
+ } catch {
556
+ body = text;
557
+ }
558
+ return { ok: response.ok, status: response.status, body };
559
+ } catch (error) {
560
+ return { ok: false, status: 0, body: summarizeError(error) };
561
+ }
562
+ }
563
+
564
+ async function classifyScheduleError(
565
+ error: unknown,
566
+ connection: AgentDaemonConnection,
567
+ ): Promise<RoutineSchedulePromotionFailure> {
568
+ const message = summarizeError(error);
569
+ const lower = message.toLowerCase();
570
+ if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('auth')) {
571
+ return { ok: false, kind: 'auth_required', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
572
+ }
573
+ if (lower.includes('404') || lower.includes('not found')) {
574
+ const daemon = await fetchDaemonStatus(connection);
575
+ const record = isRecord(daemon.body) ? daemon.body : {};
576
+ const daemonVersion = readString(record, 'version') ?? 'unknown';
577
+ if (daemon.ok && daemonVersion !== SDK_VERSION) {
578
+ return {
579
+ ok: false,
580
+ kind: 'version_mismatch',
581
+ error: `External daemon SDK version ${daemonVersion} does not match Agent SDK pin ${SDK_VERSION}; schedules.create is unavailable.`,
582
+ route: ROUTINE_SCHEDULE_ROUTE,
583
+ baseUrl: connection.baseUrl,
584
+ daemonVersion,
585
+ expectedSdkVersion: SDK_VERSION,
586
+ };
587
+ }
588
+ return { ok: false, kind: 'daemon_route_unavailable', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
589
+ }
590
+ if (lower.includes('fetch') || lower.includes('connect') || lower.includes('econnrefused')) {
591
+ return { ok: false, kind: 'daemon_unavailable', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
592
+ }
593
+ return { ok: false, kind: 'daemon_error', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
594
+ }
595
+
596
+ export async function promoteRoutineToDaemonSchedule(
597
+ connection: AgentDaemonConnection,
598
+ preview: RoutineSchedulePromotionPreview,
599
+ ): Promise<RoutineSchedulePromotionResult> {
600
+ if (!connection.token) {
601
+ return {
602
+ ok: false,
603
+ kind: 'auth_required',
604
+ error: `No daemon operator token found at ${connection.tokenPath}`,
605
+ route: ROUTINE_SCHEDULE_ROUTE,
606
+ baseUrl: connection.baseUrl,
607
+ };
608
+ }
609
+ try {
610
+ const sdk = createBrowserGoodVibesSdk({ baseUrl: connection.baseUrl, authToken: connection.token });
611
+ const schedule = await sdk.operator.invoke(ROUTINE_SCHEDULE_METHOD, preview.payload);
612
+ return {
613
+ ok: true,
614
+ kind: ROUTINE_SCHEDULE_METHOD,
615
+ route: ROUTINE_SCHEDULE_ROUTE,
616
+ routineId: preview.routineId,
617
+ routineName: preview.routineName,
618
+ schedule,
619
+ request: preview.payload,
620
+ };
621
+ } catch (error) {
622
+ return classifyScheduleError(error, connection);
623
+ }
624
+ }
625
+
626
+ export function formatRoutineSchedulePreview(preview: RoutineSchedulePromotionPreview): string {
627
+ const schedule = preview.payload.kind === 'cron'
628
+ ? `${preview.payload.cron}${preview.payload.timezone ? ` [${preview.payload.timezone}]` : ''}`
629
+ : preview.payload.kind === 'every'
630
+ ? String(preview.payload.every)
631
+ : String(preview.payload.at);
632
+ return [
633
+ 'Daemon schedule preview for Agent routine',
634
+ ` routine: ${preview.routineName} (${preview.routineId})`,
635
+ ` route: ${preview.method} ${preview.route}`,
636
+ ` name: ${String(preview.payload.name ?? '(daemon default)')}`,
637
+ ` schedule: ${preview.payload.kind} ${schedule}`,
638
+ ` enabled: ${preview.payload.enabled === false ? 'no' : 'yes'}`,
639
+ ' target: external daemon service/main conversation route',
640
+ ' policy: isolated Agent Knowledge only; no default wiki/HomeGraph fallback; no WRFC unless explicitly delegated',
641
+ ' next: rerun with --yes to create this daemon schedule',
642
+ ].join('\n');
643
+ }
644
+
645
+ export function formatRoutineScheduleSuccess(result: RoutineSchedulePromotionSuccess): string {
646
+ const record: Record<string, unknown> = isRecord(result.schedule) ? result.schedule : {};
647
+ const id = readString(record, 'id') ?? '(unknown)';
648
+ const status = readString(record, 'status') ?? (record.enabled === false ? 'paused' : 'enabled');
649
+ return [
650
+ 'Created daemon schedule for Agent routine',
651
+ ` routine: ${result.routineName} (${result.routineId})`,
652
+ ` schedule: ${id}`,
653
+ ` status: ${status}`,
654
+ ` route: ${result.kind} ${result.route}`,
655
+ ' next: inspect with /schedule list or daemon schedule observability',
656
+ ].join('\n');
657
+ }
658
+
659
+ export function formatRoutineScheduleReceipts(snapshot: RoutineScheduleReceiptSnapshot, limit = 10): string {
660
+ const receipts = snapshot.receipts.slice(0, Math.max(1, limit));
661
+ if (snapshot.receipts.length === 0) {
662
+ return [
663
+ 'Agent routine schedule receipts',
664
+ ` store: ${snapshot.path}`,
665
+ ' No routine schedule promotions have been recorded yet.',
666
+ ' Create one with /schedule promote-routine <routine-id> --cron <expr> --yes.',
667
+ ].join('\n');
668
+ }
669
+ return [
670
+ `Agent routine schedule receipts (${snapshot.receipts.length})`,
671
+ ` store: ${snapshot.path}`,
672
+ ...receipts.map((receipt) => {
673
+ const schedule = receipt.scheduleId ? ` schedule=${receipt.scheduleId}` : '';
674
+ const failure = receipt.status === 'failed' && receipt.failureKind ? ` failure=${receipt.failureKind}` : '';
675
+ return ` ${receipt.id} ${receipt.status} ${receipt.scheduleKind} ${receipt.scheduleValue} routine=${receipt.routineId}${schedule}${failure}`;
676
+ }),
677
+ snapshot.receipts.length > receipts.length ? ` ...${snapshot.receipts.length - receipts.length} more` : '',
678
+ ].filter((line): line is string => Boolean(line)).join('\n');
679
+ }
680
+
681
+ export function formatRoutineScheduleReceipt(receipt: RoutineScheduleReceipt): string {
682
+ return [
683
+ `Agent routine schedule receipt ${receipt.id}`,
684
+ ` created: ${receipt.createdAt}`,
685
+ ` status: ${receipt.status}`,
686
+ ` routine: ${receipt.routineName} (${receipt.routineId})`,
687
+ ` route: ${receipt.method} ${receipt.route}`,
688
+ ` daemon: ${receipt.daemonBaseUrl}`,
689
+ ` schedule: ${receipt.scheduleName}${receipt.scheduleId ? ` (${receipt.scheduleId})` : ''}`,
690
+ receipt.scheduleStatus ? ` schedule status: ${receipt.scheduleStatus}` : '',
691
+ ` cadence: ${receipt.scheduleKind} ${receipt.scheduleValue}${receipt.timezone ? ` [${receipt.timezone}]` : ''}`,
692
+ ` enabled: ${receipt.enabled ? 'yes' : 'no'}`,
693
+ receipt.provider ? ` provider: ${receipt.provider}` : '',
694
+ receipt.model ? ` model: ${receipt.model}` : '',
695
+ ` target: ${receipt.target.kind ?? 'unknown'}${receipt.target.surfaceKind ? `/${receipt.target.surfaceKind}` : ''}`,
696
+ receipt.deliveryMode ? ` delivery: ${receipt.deliveryMode}` : '',
697
+ receipt.failureKind ? ` failure: ${receipt.failureKind}` : '',
698
+ receipt.failureError ? ` error: ${receipt.failureError}` : '',
699
+ ].filter((line): line is string => Boolean(line)).join('\n');
700
+ }
701
+
702
+ export function formatRoutineScheduleFailure(failure: RoutineSchedulePromotionFailure): string {
703
+ return [
704
+ `Daemon schedule error: ${failure.kind}`,
705
+ ` ${failure.error}`,
706
+ failure.baseUrl ? ` daemon: ${failure.baseUrl}` : null,
707
+ ` route: ${ROUTINE_SCHEDULE_METHOD} ${failure.route}`,
708
+ failure.kind === 'version_mismatch' && failure.daemonVersion && failure.expectedSdkVersion
709
+ ? ` versions: daemon=${failure.daemonVersion} expected=${failure.expectedSdkVersion}`
710
+ : null,
711
+ failure.kind === 'auth_required'
712
+ ? ' next: pair/authenticate with the externally managed GoodVibes daemon, then retry with --yes.'
713
+ : null,
714
+ failure.kind === 'daemon_unavailable'
715
+ ? ' next: start/restart the external GoodVibes daemon from TUI or daemon host tooling; Agent does not own daemon lifecycle.'
716
+ : null,
717
+ failure.kind === 'version_mismatch' || failure.kind === 'daemon_route_unavailable'
718
+ ? ' next: update/restart the external GoodVibes daemon so public schedules.create is available.'
719
+ : null,
720
+ ].filter((line): line is string => Boolean(line)).join('\n');
721
+ }