@pellux/goodvibes-agent 0.1.36 → 0.1.37

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  All notable changes to GoodVibes Agent will be recorded here.
4
4
 
5
+ ## 0.1.37 - 2026-05-31
6
+
7
+ - 656b6f4 Add Agent routine daemon schedule promotion
8
+
5
9
  ## 0.1.36 - 2026-05-31
6
10
 
7
11
  - 9de2f5e Add guided Agent starter authoring
package/README.md CHANGED
@@ -68,10 +68,13 @@ Local Agent behavior is editable from the TUI:
68
68
  /personas use research
69
69
  /routines create --name "Evening Review" --description "Review open work before shutdown" --steps "Check work plan, approvals, and Agent Knowledge status before summarizing." --enabled true
70
70
  /routines start evening-review
71
+ /schedule promote-routine evening-review --cron "0 17 * * 1-5" --timezone America/Chicago --yes
71
72
  /agent-skills create --name "Morning Brief" --description "Daily briefing flow" --procedure "Check tasks, approvals, calendar, and unread state before summarizing." --enabled true
72
73
  /skills local list
73
74
  ```
74
75
 
76
+ Starting a routine records local usage and prints its steps; it does not spawn background agents or daemon automation jobs. Promotion to a daemon schedule is separate and explicit: it calls the public `schedules.create` route on the externally managed daemon only after `--yes`, and the generated scheduled prompt keeps Agent Knowledge isolated from default Knowledge/Wiki and HomeGraph.
77
+
75
78
  ## Daemon Prerequisite
76
79
 
77
80
  Start or restart the daemon from GoodVibes TUI or the daemon host before launching Agent. Agent status and companion/knowledge routes connect to that external daemon, normally on `http://127.0.0.1:3421`.
@@ -70,12 +70,13 @@ Personas, routines, and reusable Agent skills are local to GoodVibes Agent. They
70
70
  /personas use research
71
71
  /routines create --name "Evening Review" --description "Review open work before shutdown" --steps "Check work plan, approvals, and Agent Knowledge status before summarizing." --enabled true
72
72
  /routines start evening-review
73
+ /schedule promote-routine evening-review --cron "0 17 * * 1-5" --timezone America/Chicago --yes
73
74
  /agent-skills create --name "Morning Brief" --description "Daily briefing flow" --procedure "Check tasks, approvals, calendar, and unread state before summarizing." --enabled true
74
75
  /agent-skills enabled
75
76
  /skills local list
76
77
  ```
77
78
 
78
- The active persona plus enabled Agent routines and skills are injected into the main serial assistant conversation. Starting a routine records local usage and prints its steps; it does not spawn background agents or daemon automation jobs.
79
+ The active persona plus enabled Agent routines and skills are injected into the main serial assistant conversation. Starting a routine records local usage and prints its steps; it does not spawn background agents or daemon automation jobs. Promoting a routine to a schedule is an explicit `schedules.create` call to the external daemon, requires `--yes`, and preserves the rule that Agent Knowledge never falls back to default Knowledge/Wiki or HomeGraph.
79
80
 
80
81
  ## External Daemon
81
82
 
@@ -53,7 +53,7 @@ Primary sources used for the benchmark:
53
53
  | Channels | WhatsApp, Telegram, Slack, Discord, Signal, iMessage, web chat | GoodVibes daemon channel and companion surfaces with Agent-side policy, a Channels operator workspace, and per-channel readiness/risk labels |
54
54
  | Knowledge/memory | Durable memory, semantic search, wiki/claim layers | Isolated Agent Knowledge routes with workspace ask/search/ingest/review flows plus local memory/skills/personas/routines |
55
55
  | Skills/procedural memory | Skills directories, registries, skill lifecycle | Local Agent skills with review/stale/source/provenance fields |
56
- | Scheduling | Natural-language cron, run/pause/resume/edit/remove, delivery | Guarded automation/schedule routes plus local routines; hidden model scheduling blocked |
56
+ | Scheduling | Natural-language cron, run/pause/resume/edit/remove, delivery | Local routines can be explicitly promoted to external daemon `schedules.create` with `--yes`; hidden model scheduling and local scheduler spawns are blocked |
57
57
  | Tools/MCP | Broad toolsets, MCP, browser, media, terminal, files | GoodVibes SDK tools with Agent policy guards and MCP/provider integrations |
58
58
  | Voice/media/canvas/nodes | Voice, TTS, mobile nodes, live canvas, browser automation | GoodVibes media/voice/browser/node primitives with an Agent workspace for setup, image input, browser posture, MCP, and remote/node inspection |
59
59
  | Build/code work | Direct terminal/file/code tools and subagents | Explicit delegation to GoodVibes TUI; local WRFC/spawn fanout blocked |
@@ -66,6 +66,7 @@ GoodVibes Agent should exceed OpenClaw/Hermes by making these properties true fr
66
66
 
67
67
  - Capability surfaces are discoverable through `goodvibes-agent capabilities`, `/capabilities`, onboarding, and the operator workspace.
68
68
  - Agent Knowledge isolation is a release gate, not a convention.
69
+ - Routine-to-schedule promotion preserves Agent Knowledge isolation and uses only public external daemon schedule routes.
69
70
  - Model-visible tools are policy-gated for serial, non-secret, non-destructive use.
70
71
  - Personal assistant state is Agent-local unless an explicit Agent Knowledge ingest route is used.
71
72
  - Build work is delegated to the product that owns coding execution instead of turning the personal operator into a second coding TUI.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-agent",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "private": false,
5
5
  "description": "Near-fork GoodVibes operator assistant with the GoodVibes TUI shell, renderer, input, fullscreen workspace, and daemon-connected Agent product brain.",
6
6
  "type": "module",
@@ -0,0 +1,434 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { 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 { getModelIdFromProviderModel, getProviderIdFromModel } from '../config/provider-model.ts';
7
+ import { SDK_VERSION } from '../version.ts';
8
+ import type { AgentRoutineRecord } from './routine-registry.ts';
9
+
10
+ export const ROUTINE_SCHEDULE_ROUTE = '/api/automation/schedules';
11
+ export const ROUTINE_SCHEDULE_METHOD = 'schedules.create';
12
+
13
+ type ScheduleCreateInput = OperatorMethodInput<'schedules.create'>;
14
+ type ScheduleCreateOutput = OperatorMethodOutput<'schedules.create'>;
15
+
16
+ export interface AgentDaemonConfigReader {
17
+ get(key: string): unknown;
18
+ }
19
+
20
+ export interface AgentDaemonConnection {
21
+ readonly baseUrl: string;
22
+ readonly token: string | null;
23
+ readonly tokenPath: string;
24
+ }
25
+
26
+ export type RoutineScheduleKind = 'cron' | 'every' | 'at';
27
+
28
+ export interface RoutineScheduleSpec {
29
+ readonly kind: RoutineScheduleKind;
30
+ readonly value: string;
31
+ }
32
+
33
+ export interface ParsedRoutineSchedulePromotionArgs {
34
+ readonly routineId: string | null;
35
+ readonly schedule: RoutineScheduleSpec | null;
36
+ readonly name?: string;
37
+ readonly timezone?: string;
38
+ readonly provider?: string;
39
+ readonly model?: string;
40
+ readonly enabled: boolean;
41
+ readonly yes: boolean;
42
+ readonly errors: readonly string[];
43
+ }
44
+
45
+ export interface RoutineSchedulePromotionPreview {
46
+ readonly routineId: string;
47
+ readonly routineName: string;
48
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
49
+ readonly method: typeof ROUTINE_SCHEDULE_METHOD;
50
+ readonly payload: ScheduleCreateInput;
51
+ }
52
+
53
+ export interface RoutineSchedulePromotionSuccess {
54
+ readonly ok: true;
55
+ readonly kind: typeof ROUTINE_SCHEDULE_METHOD;
56
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
57
+ readonly routineId: string;
58
+ readonly routineName: string;
59
+ readonly schedule: ScheduleCreateOutput;
60
+ readonly request: ScheduleCreateInput;
61
+ }
62
+
63
+ export interface RoutineSchedulePromotionFailure {
64
+ readonly ok: false;
65
+ readonly kind:
66
+ | 'confirmation_required'
67
+ | 'auth_required'
68
+ | 'daemon_unavailable'
69
+ | 'version_mismatch'
70
+ | 'daemon_route_unavailable'
71
+ | 'daemon_error';
72
+ readonly error: string;
73
+ readonly route: typeof ROUTINE_SCHEDULE_ROUTE;
74
+ readonly baseUrl?: string;
75
+ readonly daemonVersion?: string;
76
+ readonly expectedSdkVersion?: string;
77
+ }
78
+
79
+ export type RoutineSchedulePromotionResult =
80
+ | RoutineSchedulePromotionSuccess
81
+ | RoutineSchedulePromotionFailure;
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
85
+ }
86
+
87
+ function readString(record: Record<string, unknown>, key: string): string | null {
88
+ const value = record[key];
89
+ return typeof value === 'string' ? value : null;
90
+ }
91
+
92
+ function optionValue(args: readonly string[], index: number, inlineValue: string | undefined): {
93
+ readonly value: string | undefined;
94
+ readonly nextIndex: number;
95
+ } {
96
+ if (inlineValue !== undefined) return { value: inlineValue, nextIndex: index };
97
+ const next = args[index + 1];
98
+ if (next === undefined || next.startsWith('--')) return { value: undefined, nextIndex: index };
99
+ return { value: next, nextIndex: index + 1 };
100
+ }
101
+
102
+ function normalizeProviderModel(provider: string | undefined, model: string | undefined): {
103
+ readonly provider?: string;
104
+ readonly model?: string;
105
+ } {
106
+ if (!model) return provider ? { provider } : {};
107
+ const normalizedProvider = provider ?? getProviderIdFromModel(model);
108
+ return {
109
+ provider: normalizedProvider,
110
+ model: getModelIdFromProviderModel(model),
111
+ };
112
+ }
113
+
114
+ export function parseRoutineSchedulePromotionArgs(args: readonly string[]): ParsedRoutineSchedulePromotionArgs {
115
+ let routineId: string | null = null;
116
+ let schedule: RoutineScheduleSpec | null = null;
117
+ let name: string | undefined;
118
+ let timezone: string | undefined;
119
+ let provider: string | undefined;
120
+ let model: string | undefined;
121
+ let enabled = true;
122
+ let yes = false;
123
+ const errors: string[] = [];
124
+
125
+ for (let index = 0; index < args.length; index += 1) {
126
+ const raw = args[index] ?? '';
127
+ const equals = raw.indexOf('=');
128
+ const optionName = equals >= 0 ? raw.slice(0, equals) : raw;
129
+ const inlineValue = equals >= 0 ? raw.slice(equals + 1) : undefined;
130
+
131
+ if (raw === '--yes') {
132
+ yes = true;
133
+ continue;
134
+ }
135
+ if (raw === '--disabled') {
136
+ enabled = false;
137
+ continue;
138
+ }
139
+ if (optionName === '--name' || optionName === '--timezone' || optionName === '--provider' || optionName === '--model') {
140
+ const consumed = optionValue(args, index, inlineValue);
141
+ index = consumed.nextIndex;
142
+ const value = consumed.value?.trim();
143
+ if (!value) {
144
+ errors.push(`${optionName} requires a value.`);
145
+ continue;
146
+ }
147
+ if (optionName === '--name') name = value;
148
+ if (optionName === '--timezone') timezone = value;
149
+ if (optionName === '--provider') provider = value;
150
+ if (optionName === '--model') model = value;
151
+ continue;
152
+ }
153
+ if (optionName === '--cron' || optionName === '--every' || optionName === '--at') {
154
+ const consumed = optionValue(args, index, inlineValue);
155
+ index = consumed.nextIndex;
156
+ const value = consumed.value?.trim();
157
+ if (!value) {
158
+ errors.push(`${optionName} requires a value.`);
159
+ continue;
160
+ }
161
+ if (schedule) {
162
+ errors.push('Choose exactly one schedule selector: --cron, --every, or --at.');
163
+ continue;
164
+ }
165
+ schedule = {
166
+ kind: optionName === '--cron' ? 'cron' : optionName === '--every' ? 'every' : 'at',
167
+ value,
168
+ };
169
+ continue;
170
+ }
171
+ if (raw.startsWith('--')) {
172
+ errors.push(`Unknown option: ${raw}`);
173
+ continue;
174
+ }
175
+ if (!routineId) {
176
+ routineId = raw;
177
+ continue;
178
+ }
179
+ errors.push(`Unexpected argument: ${raw}`);
180
+ }
181
+
182
+ if (!routineId) errors.push('Routine id or name is required.');
183
+ if (!schedule) errors.push('Schedule is required: use --cron <expr>, --every <interval>, or --at <iso-time>.');
184
+ return { routineId, schedule, name, timezone, provider, model, enabled, yes, errors };
185
+ }
186
+
187
+ export function resolveAgentDaemonConnection(
188
+ configManager: AgentDaemonConfigReader,
189
+ homeDirectory: string,
190
+ ): AgentDaemonConnection {
191
+ const host = String(configManager.get('controlPlane.host') ?? '127.0.0.1');
192
+ const port = Number(configManager.get('controlPlane.port') ?? 3421);
193
+ const tokenPath = join(homeDirectory, '.goodvibes', 'daemon', 'operator-tokens.json');
194
+ if (!existsSync(tokenPath)) return { baseUrl: `http://${host}:${Number.isFinite(port) ? port : 3421}`, token: null, tokenPath };
195
+ try {
196
+ const parsed = JSON.parse(readFileSync(tokenPath, 'utf-8')) as unknown;
197
+ const token = isRecord(parsed) && typeof parsed.token === 'string' ? parsed.token : null;
198
+ return { baseUrl: `http://${host}:${Number.isFinite(port) ? port : 3421}`, token, tokenPath };
199
+ } catch {
200
+ return { baseUrl: `http://${host}:${Number.isFinite(port) ? port : 3421}`, token: null, tokenPath };
201
+ }
202
+ }
203
+
204
+ export function buildRoutineSchedulePrompt(routine: AgentRoutineRecord): string {
205
+ return [
206
+ 'GoodVibes Agent scheduled routine.',
207
+ '',
208
+ `Routine: ${routine.name}`,
209
+ `Routine id: ${routine.id}`,
210
+ `Review state: ${routine.reviewState}`,
211
+ `Tags: ${routine.tags.join(', ') || '(none)'}`,
212
+ `Triggers: ${routine.triggers.join(', ') || '(manual)'}`,
213
+ '',
214
+ 'Operator policy:',
215
+ '- Run this as a serial GoodVibes Agent operator routine.',
216
+ '- Use isolated Agent Knowledge routes only; never use default Knowledge/Wiki or HomeGraph as fallback.',
217
+ '- Do not perform destructive, costly, externally visible, or secret-handling actions without explicit approval.',
218
+ '- Do not request WRFC unless this scheduled routine explicitly delegates build/fix/review work to GoodVibes TUI.',
219
+ '- Summarize what was checked, what changed, and what still needs user review.',
220
+ '',
221
+ 'Routine description:',
222
+ routine.description,
223
+ '',
224
+ 'Routine steps:',
225
+ routine.steps,
226
+ ].join('\n');
227
+ }
228
+
229
+ export function buildRoutineSchedulePayload(
230
+ routine: AgentRoutineRecord,
231
+ parsed: ParsedRoutineSchedulePromotionArgs,
232
+ ): ScheduleCreateInput {
233
+ if (!parsed.schedule) throw new Error('Schedule is required.');
234
+ const modelRoute = normalizeProviderModel(parsed.provider, parsed.model);
235
+ const payload: ScheduleCreateInput = {
236
+ name: parsed.name ?? `Agent routine: ${routine.name}`,
237
+ prompt: buildRoutineSchedulePrompt(routine),
238
+ kind: parsed.schedule.kind,
239
+ enabled: parsed.enabled,
240
+ target: {
241
+ kind: 'main',
242
+ surfaceKind: 'service',
243
+ preserveThread: true,
244
+ createIfMissing: true,
245
+ },
246
+ delivery: {
247
+ mode: 'none',
248
+ targets: [],
249
+ fallbackTargets: [],
250
+ includeSummary: true,
251
+ includeTranscript: false,
252
+ includeLinks: true,
253
+ },
254
+ failure: {
255
+ action: 'retry',
256
+ maxConsecutiveFailures: 3,
257
+ cooldownMs: 3_600_000,
258
+ retryPolicy: {
259
+ maxAttempts: 2,
260
+ delayMs: 60_000,
261
+ strategy: 'exponential',
262
+ maxDelayMs: 900_000,
263
+ jitterMs: 30_000,
264
+ },
265
+ disableAfterFailures: false,
266
+ },
267
+ lightContext: true,
268
+ autoApprove: false,
269
+ allowUnsafeExternalContent: false,
270
+ ...modelRoute,
271
+ };
272
+ if (parsed.schedule.kind === 'cron') {
273
+ return {
274
+ ...payload,
275
+ cron: parsed.schedule.value,
276
+ timezone: parsed.timezone,
277
+ };
278
+ }
279
+ if (parsed.schedule.kind === 'every') {
280
+ return { ...payload, every: parsed.schedule.value };
281
+ }
282
+ return { ...payload, at: parsed.schedule.value };
283
+ }
284
+
285
+ export function buildRoutineSchedulePreview(
286
+ routine: AgentRoutineRecord,
287
+ parsed: ParsedRoutineSchedulePromotionArgs,
288
+ ): RoutineSchedulePromotionPreview {
289
+ return {
290
+ routineId: routine.id,
291
+ routineName: routine.name,
292
+ route: ROUTINE_SCHEDULE_ROUTE,
293
+ method: ROUTINE_SCHEDULE_METHOD,
294
+ payload: buildRoutineSchedulePayload(routine, parsed),
295
+ };
296
+ }
297
+
298
+ async function fetchDaemonStatus(connection: AgentDaemonConnection): Promise<{
299
+ readonly ok: boolean;
300
+ readonly status: number;
301
+ readonly body: unknown;
302
+ }> {
303
+ try {
304
+ const response = await fetch(`${connection.baseUrl}/status`, {
305
+ headers: connection.token ? { authorization: `Bearer ${connection.token}` } : undefined,
306
+ });
307
+ const text = await response.text();
308
+ let body: unknown = text;
309
+ try {
310
+ body = text.trim() ? JSON.parse(text) as unknown : {};
311
+ } catch {
312
+ body = text;
313
+ }
314
+ return { ok: response.ok, status: response.status, body };
315
+ } catch (error) {
316
+ return { ok: false, status: 0, body: summarizeError(error) };
317
+ }
318
+ }
319
+
320
+ async function classifyScheduleError(
321
+ error: unknown,
322
+ connection: AgentDaemonConnection,
323
+ ): Promise<RoutineSchedulePromotionFailure> {
324
+ const message = summarizeError(error);
325
+ const lower = message.toLowerCase();
326
+ if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('auth')) {
327
+ return { ok: false, kind: 'auth_required', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
328
+ }
329
+ if (lower.includes('404') || lower.includes('not found')) {
330
+ const daemon = await fetchDaemonStatus(connection);
331
+ const record = isRecord(daemon.body) ? daemon.body : {};
332
+ const daemonVersion = readString(record, 'version') ?? 'unknown';
333
+ if (daemon.ok && daemonVersion !== SDK_VERSION) {
334
+ return {
335
+ ok: false,
336
+ kind: 'version_mismatch',
337
+ error: `External daemon SDK version ${daemonVersion} does not match Agent SDK pin ${SDK_VERSION}; schedules.create is unavailable.`,
338
+ route: ROUTINE_SCHEDULE_ROUTE,
339
+ baseUrl: connection.baseUrl,
340
+ daemonVersion,
341
+ expectedSdkVersion: SDK_VERSION,
342
+ };
343
+ }
344
+ return { ok: false, kind: 'daemon_route_unavailable', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
345
+ }
346
+ if (lower.includes('fetch') || lower.includes('connect') || lower.includes('econnrefused')) {
347
+ return { ok: false, kind: 'daemon_unavailable', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
348
+ }
349
+ return { ok: false, kind: 'daemon_error', error: message, route: ROUTINE_SCHEDULE_ROUTE, baseUrl: connection.baseUrl };
350
+ }
351
+
352
+ export async function promoteRoutineToDaemonSchedule(
353
+ connection: AgentDaemonConnection,
354
+ preview: RoutineSchedulePromotionPreview,
355
+ ): Promise<RoutineSchedulePromotionResult> {
356
+ if (!connection.token) {
357
+ return {
358
+ ok: false,
359
+ kind: 'auth_required',
360
+ error: `No daemon operator token found at ${connection.tokenPath}`,
361
+ route: ROUTINE_SCHEDULE_ROUTE,
362
+ baseUrl: connection.baseUrl,
363
+ };
364
+ }
365
+ try {
366
+ const sdk = createBrowserGoodVibesSdk({ baseUrl: connection.baseUrl, authToken: connection.token });
367
+ const schedule = await sdk.operator.invoke(ROUTINE_SCHEDULE_METHOD, preview.payload);
368
+ return {
369
+ ok: true,
370
+ kind: ROUTINE_SCHEDULE_METHOD,
371
+ route: ROUTINE_SCHEDULE_ROUTE,
372
+ routineId: preview.routineId,
373
+ routineName: preview.routineName,
374
+ schedule,
375
+ request: preview.payload,
376
+ };
377
+ } catch (error) {
378
+ return classifyScheduleError(error, connection);
379
+ }
380
+ }
381
+
382
+ export function formatRoutineSchedulePreview(preview: RoutineSchedulePromotionPreview): string {
383
+ const schedule = preview.payload.kind === 'cron'
384
+ ? `${preview.payload.cron}${preview.payload.timezone ? ` [${preview.payload.timezone}]` : ''}`
385
+ : preview.payload.kind === 'every'
386
+ ? String(preview.payload.every)
387
+ : String(preview.payload.at);
388
+ return [
389
+ 'Daemon schedule preview for Agent routine',
390
+ ` routine: ${preview.routineName} (${preview.routineId})`,
391
+ ` route: ${preview.method} ${preview.route}`,
392
+ ` name: ${String(preview.payload.name ?? '(daemon default)')}`,
393
+ ` schedule: ${preview.payload.kind} ${schedule}`,
394
+ ` enabled: ${preview.payload.enabled === false ? 'no' : 'yes'}`,
395
+ ' target: external daemon service/main conversation route',
396
+ ' policy: isolated Agent Knowledge only; no default wiki/HomeGraph fallback; no WRFC unless explicitly delegated',
397
+ ' next: rerun with --yes to create this daemon schedule',
398
+ ].join('\n');
399
+ }
400
+
401
+ export function formatRoutineScheduleSuccess(result: RoutineSchedulePromotionSuccess): string {
402
+ const record: Record<string, unknown> = isRecord(result.schedule) ? result.schedule : {};
403
+ const id = readString(record, 'id') ?? '(unknown)';
404
+ const status = readString(record, 'status') ?? (record.enabled === false ? 'paused' : 'enabled');
405
+ return [
406
+ 'Created daemon schedule for Agent routine',
407
+ ` routine: ${result.routineName} (${result.routineId})`,
408
+ ` schedule: ${id}`,
409
+ ` status: ${status}`,
410
+ ` route: ${result.kind} ${result.route}`,
411
+ ' next: inspect with /schedule list or daemon schedule observability',
412
+ ].join('\n');
413
+ }
414
+
415
+ export function formatRoutineScheduleFailure(failure: RoutineSchedulePromotionFailure): string {
416
+ return [
417
+ `Daemon schedule error: ${failure.kind}`,
418
+ ` ${failure.error}`,
419
+ failure.baseUrl ? ` daemon: ${failure.baseUrl}` : null,
420
+ ` route: ${ROUTINE_SCHEDULE_METHOD} ${failure.route}`,
421
+ failure.kind === 'version_mismatch' && failure.daemonVersion && failure.expectedSdkVersion
422
+ ? ` versions: daemon=${failure.daemonVersion} expected=${failure.expectedSdkVersion}`
423
+ : null,
424
+ failure.kind === 'auth_required'
425
+ ? ' next: pair/authenticate with the externally managed GoodVibes daemon, then retry with --yes.'
426
+ : null,
427
+ failure.kind === 'daemon_unavailable'
428
+ ? ' next: start/restart the external GoodVibes daemon from TUI or daemon host tooling; Agent does not own daemon lifecycle.'
429
+ : null,
430
+ failure.kind === 'version_mismatch' || failure.kind === 'daemon_route_unavailable'
431
+ ? ' next: update/restart the external GoodVibes daemon so public schedules.create is available.'
432
+ : null,
433
+ ].filter((line): line is string => Boolean(line)).join('\n');
434
+ }
@@ -12,6 +12,7 @@ const COMMANDS = [
12
12
  'models',
13
13
  'providers',
14
14
  'profiles',
15
+ 'routines',
15
16
  'auth',
16
17
  'compat',
17
18
  'capabilities',
package/src/cli/help.ts CHANGED
@@ -39,6 +39,7 @@ export function renderGoodVibesHelp(binary = 'goodvibes-agent'): string {
39
39
  ' models [provider] List/use/pin selectable models and recent model history',
40
40
  ' providers List/inspect/use provider config/auth posture',
41
41
  ' profiles Manage isolated Agent runtime profile homes',
42
+ ' routines Inspect local routines and explicitly promote one to a daemon schedule',
42
43
  ' auth Inspect and manage local users, sessions, and bootstrap auth',
43
44
  ' compat Inspect Agent SDK pin, daemon version, and Agent knowledge route readiness',
44
45
  ' capabilities Show OpenClaw/Hermes capability parity and Agent readiness',
@@ -98,6 +99,7 @@ export function renderGoodVibesHelp(binary = 'goodvibes-agent'): string {
98
99
  ` ${binary} providers inspect openai`,
99
100
  ` ${binary} profiles create household --template household --yes`,
100
101
  ` ${binary} --agent-profile household`,
102
+ ` ${binary} routines promote daily-operations-sweep --cron "0 9 * * *" --timezone America/Chicago --yes`,
101
103
  ` ${binary} compat`,
102
104
  ` ${binary} capabilities`,
103
105
  ` ${binary} knowledge status`,
@@ -158,6 +160,21 @@ const COMMAND_HELP: Record<string, CommandHelp> = {
158
160
  summary: 'Create and inspect isolated Agent runtime profile homes, with starter templates for household, research, travel, operations, personal productivity, and local imported starters. A profile changes Agent-local config, sessions, memory, personas, skills, routines, and setup paths without changing the externally owned daemon.',
159
161
  examples: ['profiles templates', 'profiles templates export research ./research-starter.json --yes', 'profiles templates import ./research-starter.json --yes', 'profiles create household --template household --yes', '--agent-profile household status'],
160
162
  },
163
+ routines: {
164
+ usage: [
165
+ 'routines list',
166
+ 'routines enabled',
167
+ 'routines show <id>',
168
+ 'routines promote <id> (--cron <expr>|--every <interval>|--at <iso-time>) [--timezone <tz>] [--name <schedule-name>] [--provider <id>] [--model <model>] [--disabled] --yes',
169
+ ],
170
+ summary: 'Inspect Agent-local routines and explicitly promote a reviewed routine into an external daemon schedule. Without --yes, promote only prints the schedules.create preview.',
171
+ examples: [
172
+ 'routines list',
173
+ 'routines show daily-operations-sweep',
174
+ 'routines promote daily-operations-sweep --cron "0 9 * * *" --timezone America/Chicago --yes',
175
+ 'routines promote weekly-review --every 7d --disabled',
176
+ ],
177
+ },
161
178
  models: {
162
179
  usage: ['models [provider]', 'models current', 'models use <registryKey>', 'models pin <registryKey>', 'models recent'],
163
180
  summary: 'List, inspect, select, pin, and review model choices.',
@@ -35,6 +35,7 @@ import { buildControlPlaneStatusResult, formatControlPlaneStatus, handleSecrets,
35
35
  import { handleAgentKnowledgeCommand, handleAgentKnowledgeShortcutCommand, handleCompatCommand, handleDelegateCommand } from './agent-knowledge-command.ts';
36
36
  import { handleCapabilitiesCommand } from './capabilities-command.ts';
37
37
  import { handleProfilesCommand } from './profiles-command.ts';
38
+ import { handleRoutinesCommand } from './routines-command.ts';
38
39
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
39
40
 
40
41
  export interface CliCommandRuntime {
@@ -710,6 +711,11 @@ export async function handleGoodVibesCliCommand(runtime: CliCommandRuntime): Pro
710
711
  console.log(result.output);
711
712
  return { handled: true, exitCode: result.exitCode };
712
713
  }
714
+ case 'routines': {
715
+ const result = await handleRoutinesCommand(runtime);
716
+ console.log(result.output);
717
+ return { handled: true, exitCode: result.exitCode };
718
+ }
713
719
  case 'knowledge': {
714
720
  const result = await handleAgentKnowledgeCommand(runtime);
715
721
  console.log(result.output);
package/src/cli/parser.ts CHANGED
@@ -27,6 +27,8 @@ const COMMAND_ALIASES: Readonly<Record<string, GoodVibesCliCommand>> = {
27
27
  provider: 'providers',
28
28
  profiles: 'profiles',
29
29
  profile: 'profiles',
30
+ routines: 'routines',
31
+ routine: 'routines',
30
32
  auth: 'auth',
31
33
  compat: 'compat',
32
34
  compatibility: 'compat',
@@ -0,0 +1,185 @@
1
+ import { createShellPathService } from '@/runtime/index.ts';
2
+ import { AgentRoutineRegistry, type AgentRoutineRecord } from '../agent/routine-registry.ts';
3
+ import {
4
+ buildRoutineSchedulePreview,
5
+ formatRoutineScheduleFailure,
6
+ formatRoutineSchedulePreview,
7
+ formatRoutineScheduleSuccess,
8
+ parseRoutineSchedulePromotionArgs,
9
+ promoteRoutineToDaemonSchedule,
10
+ resolveAgentDaemonConnection,
11
+ } from '../agent/routine-schedule-promotion.ts';
12
+ import type { CliCommandOutput } from './types.ts';
13
+ import type { CliCommandRuntime } from './management.ts';
14
+
15
+ interface RoutinesCommandSuccess<TData> {
16
+ readonly ok: true;
17
+ readonly kind: string;
18
+ readonly data: TData;
19
+ }
20
+
21
+ interface RoutinesCommandFailure {
22
+ readonly ok: false;
23
+ readonly kind: string;
24
+ readonly error: string;
25
+ }
26
+
27
+ function jsonOrText(runtime: CliCommandRuntime, value: unknown, text: string): string {
28
+ return runtime.cli.flags.outputFormat === 'json' ? JSON.stringify(value, null, 2) : text;
29
+ }
30
+
31
+ function routineRegistry(runtime: CliCommandRuntime): AgentRoutineRegistry {
32
+ return AgentRoutineRegistry.fromShellPaths(createShellPathService({
33
+ workingDirectory: runtime.workingDirectory,
34
+ homeDirectory: runtime.homeDirectory,
35
+ }));
36
+ }
37
+
38
+ function summarizeRoutine(routine: AgentRoutineRecord): string {
39
+ const enabled = routine.enabled ? 'enabled' : 'disabled';
40
+ const tags = routine.tags.length > 0 ? ` tags=${routine.tags.join(',')}` : '';
41
+ return ` ${routine.id} ${enabled} ${routine.reviewState} starts=${routine.startCount} ${routine.name} - ${routine.description}${tags}`;
42
+ }
43
+
44
+ function renderRoutineList(title: string, path: string, routines: readonly AgentRoutineRecord[]): string {
45
+ if (routines.length === 0) {
46
+ return [
47
+ title,
48
+ ' No local Agent routines yet.',
49
+ ' Create routines inside the Agent TUI with /routines create, or create a runtime profile from a starter template.',
50
+ ].join('\n');
51
+ }
52
+ return [
53
+ `${title} (${routines.length})`,
54
+ ` store: ${path}`,
55
+ ...routines.map(summarizeRoutine),
56
+ ].join('\n');
57
+ }
58
+
59
+ function renderRoutine(routine: AgentRoutineRecord): string {
60
+ return [
61
+ `Routine ${routine.name}`,
62
+ ` id: ${routine.id}`,
63
+ ` enabled: ${routine.enabled ? 'yes' : 'no'}`,
64
+ ` review: ${routine.reviewState}`,
65
+ ` source: ${routine.source}`,
66
+ ` provenance: ${routine.provenance}`,
67
+ ` tags: ${routine.tags.join(', ') || '(none)'}`,
68
+ ` triggers: ${routine.triggers.join(', ') || '(manual)'}`,
69
+ ` started: ${routine.startCount}${routine.lastStartedAt ? `; last ${routine.lastStartedAt}` : ''}`,
70
+ ` created: ${routine.createdAt}`,
71
+ ` updated: ${routine.updatedAt}`,
72
+ routine.staleReason ? ` stale reason: ${routine.staleReason}` : '',
73
+ '',
74
+ routine.description,
75
+ '',
76
+ routine.steps,
77
+ ].filter((line): line is string => Boolean(line)).join('\n');
78
+ }
79
+
80
+ async function handleRoutinePromotion(runtime: CliCommandRuntime, args: readonly string[]): Promise<CliCommandOutput> {
81
+ const parsed = parseRoutineSchedulePromotionArgs(args);
82
+ const json = runtime.cli.flags.outputFormat === 'json';
83
+ if (parsed.errors.length > 0) {
84
+ const failure: RoutinesCommandFailure = {
85
+ ok: false,
86
+ kind: 'invalid_routine_schedule_promotion',
87
+ error: parsed.errors.join(' '),
88
+ };
89
+ return {
90
+ output: json ? JSON.stringify(failure, null, 2) : [
91
+ 'Usage: goodvibes-agent routines promote <id> (--cron <expr>|--every <interval>|--at <iso-time>) [--timezone <tz>] [--name <schedule-name>] [--provider <id>] [--model <model>] [--disabled] --yes',
92
+ ...parsed.errors.map((error) => ` ${error}`),
93
+ ].join('\n'),
94
+ exitCode: 2,
95
+ };
96
+ }
97
+ const registry = routineRegistry(runtime);
98
+ const routine = registry.get(parsed.routineId ?? '');
99
+ if (!routine) {
100
+ const failure: RoutinesCommandFailure = {
101
+ ok: false,
102
+ kind: 'routine_not_found',
103
+ error: `Unknown Agent routine: ${parsed.routineId ?? ''}`,
104
+ };
105
+ return {
106
+ output: json ? JSON.stringify(failure, null, 2) : failure.error,
107
+ exitCode: 1,
108
+ };
109
+ }
110
+ const preview = buildRoutineSchedulePreview(routine, parsed);
111
+ if (!parsed.yes) {
112
+ const value: RoutinesCommandSuccess<typeof preview> = {
113
+ ok: true,
114
+ kind: 'schedules.create.preview',
115
+ data: preview,
116
+ };
117
+ return {
118
+ output: jsonOrText(runtime, value, formatRoutineSchedulePreview(preview)),
119
+ exitCode: 0,
120
+ };
121
+ }
122
+ const connection = resolveAgentDaemonConnection(runtime.configManager, runtime.homeDirectory);
123
+ const result = await promoteRoutineToDaemonSchedule(connection, preview);
124
+ if (!result.ok) {
125
+ return {
126
+ output: json ? JSON.stringify(result, null, 2) : formatRoutineScheduleFailure(result),
127
+ exitCode: 1,
128
+ };
129
+ }
130
+ return {
131
+ output: jsonOrText(runtime, result, formatRoutineScheduleSuccess(result)),
132
+ exitCode: 0,
133
+ };
134
+ }
135
+
136
+ export async function handleRoutinesCommand(runtime: CliCommandRuntime): Promise<CliCommandOutput> {
137
+ const [sub = 'list', ...rest] = runtime.cli.commandArgs;
138
+ const registry = routineRegistry(runtime);
139
+ const snapshot = registry.snapshot();
140
+ const normalized = sub.toLowerCase();
141
+ if (normalized === 'list' || normalized === 'enabled') {
142
+ const routines = normalized === 'enabled' ? snapshot.enabledRoutines : snapshot.routines;
143
+ const value: RoutinesCommandSuccess<{
144
+ readonly path: string;
145
+ readonly routines: readonly AgentRoutineRecord[];
146
+ readonly enabledCount: number;
147
+ }> = {
148
+ ok: true,
149
+ kind: normalized === 'enabled' ? 'agent.routines.enabled' : 'agent.routines.list',
150
+ data: {
151
+ path: snapshot.path,
152
+ routines,
153
+ enabledCount: snapshot.enabledRoutines.length,
154
+ },
155
+ };
156
+ return {
157
+ output: jsonOrText(runtime, value, renderRoutineList(normalized === 'enabled' ? 'Enabled Agent routines' : 'Agent routines', snapshot.path, routines)),
158
+ exitCode: 0,
159
+ };
160
+ }
161
+ if (normalized === 'show') {
162
+ const id = rest[0];
163
+ if (!id) return { output: 'Usage: goodvibes-agent routines show <id>', exitCode: 2 };
164
+ const routine = registry.get(id);
165
+ if (!routine) {
166
+ const failure: RoutinesCommandFailure = { ok: false, kind: 'routine_not_found', error: `Unknown Agent routine: ${id}` };
167
+ return {
168
+ output: runtime.cli.flags.outputFormat === 'json' ? JSON.stringify(failure, null, 2) : failure.error,
169
+ exitCode: 1,
170
+ };
171
+ }
172
+ const value: RoutinesCommandSuccess<AgentRoutineRecord> = { ok: true, kind: 'agent.routines.show', data: routine };
173
+ return {
174
+ output: jsonOrText(runtime, value, renderRoutine(routine)),
175
+ exitCode: 0,
176
+ };
177
+ }
178
+ if (normalized === 'promote' || normalized === 'schedule' || normalized === 'promote-schedule') {
179
+ return handleRoutinePromotion(runtime, rest);
180
+ }
181
+ return {
182
+ output: 'Usage: goodvibes-agent routines [list|enabled|show <id>|promote <id> (--cron <expr>|--every <interval>|--at <iso-time>) --yes]',
183
+ exitCode: 2,
184
+ };
185
+ }
package/src/cli/types.ts CHANGED
@@ -10,6 +10,7 @@ export type GoodVibesCliCommand =
10
10
  | 'models'
11
11
  | 'providers'
12
12
  | 'profiles'
13
+ | 'routines'
13
14
  | 'auth'
14
15
  | 'compat'
15
16
  | 'capabilities'
@@ -572,11 +572,12 @@ export const AGENT_WORKSPACE_CATEGORIES: readonly AgentWorkspaceCategory[] = [
572
572
  id: 'automation',
573
573
  group: 'WATCH',
574
574
  label: 'Automation',
575
- summary: 'Read-only automation and schedule observability.',
576
- detail: 'Agent does not create, run, enable, disable, or remove local automation jobs. Schedule mutations wait for an Agent-safe public route and explicit approval.',
575
+ summary: 'Automation and schedule observability with explicit routine promotion.',
576
+ detail: 'Agent does not create local automation jobs or hidden scheduler spawns. Reviewed local routines can be promoted into externally owned daemon schedules only through an explicit schedules.create command with --yes.',
577
577
  actions: [
578
578
  { id: 'schedule-list', label: 'List schedules', detail: 'Inspect configured jobs and history without running or mutating them.', command: '/schedule list', kind: 'command', safety: 'read-only' },
579
- { id: 'schedule-policy', label: 'Mutation blocked', detail: 'Schedule add/run/remove/enable/disable are intentionally blocked in Agent.', kind: 'guidance', safety: 'blocked' },
579
+ { id: 'schedule-promote-routine', label: 'Promote routine', detail: 'Create an external daemon schedule from a local Agent routine. Requires a real routine id, schedule expression, and explicit --yes.', command: '/schedule promote-routine <routine-id> --cron <expr> --yes', kind: 'command', safety: 'safe' },
580
+ { id: 'schedule-policy', label: 'Local scheduler blocked', detail: 'Local schedule add/run/remove/enable/disable remain blocked; only explicit external daemon schedule promotion is allowed here.', kind: 'guidance', safety: 'blocked' },
580
581
  { id: 'health-services', label: 'Service health', detail: 'Inspect service readiness without starting, stopping, or restarting daemon services.', command: '/health services', kind: 'command', safety: 'read-only' },
581
582
  ],
582
583
  },
@@ -1,4 +1,13 @@
1
1
  import { AgentRoutineRegistry, type AgentRoutineRecord } from '../../agent/routine-registry.ts';
2
+ import {
3
+ buildRoutineSchedulePreview,
4
+ formatRoutineScheduleFailure,
5
+ formatRoutineSchedulePreview,
6
+ formatRoutineScheduleSuccess,
7
+ parseRoutineSchedulePromotionArgs,
8
+ promoteRoutineToDaemonSchedule,
9
+ resolveAgentDaemonConnection,
10
+ } from '../../agent/routine-schedule-promotion.ts';
2
11
  import type { CommandContext, CommandRegistry } from '../command-registry.ts';
3
12
  import { requireShellPaths } from './runtime-services.ts';
4
13
 
@@ -93,6 +102,31 @@ function printError(ctx: CommandContext, error: unknown): void {
93
102
  ctx.print(`Error: ${error instanceof Error ? error.message : String(error)}`);
94
103
  }
95
104
 
105
+ async function promoteRoutine(args: readonly string[], routineRegistry: AgentRoutineRegistry, ctx: CommandContext): Promise<void> {
106
+ const parsed = parseRoutineSchedulePromotionArgs(args);
107
+ if (parsed.errors.length > 0) {
108
+ ctx.print([
109
+ 'Usage: /routines promote <id> (--cron <expr>|--every <interval>|--at <iso-time>) [--timezone <tz>] [--name <schedule-name>] [--provider <id>] [--model <model>] [--disabled] --yes',
110
+ ...parsed.errors.map((error) => ` ${error}`),
111
+ ].join('\n'));
112
+ return;
113
+ }
114
+ const routine = routineRegistry.get(parsed.routineId ?? '');
115
+ if (!routine) {
116
+ ctx.print(`Unknown Agent routine: ${parsed.routineId ?? ''}`);
117
+ return;
118
+ }
119
+ const preview = buildRoutineSchedulePreview(routine, parsed);
120
+ if (!parsed.yes) {
121
+ ctx.print(formatRoutineSchedulePreview(preview));
122
+ return;
123
+ }
124
+ const shellPaths = requireShellPaths(ctx);
125
+ const connection = resolveAgentDaemonConnection(ctx.platform.configManager, shellPaths.homeDirectory);
126
+ const result = await promoteRoutineToDaemonSchedule(connection, preview);
127
+ ctx.print(result.ok ? formatRoutineScheduleSuccess(result) : formatRoutineScheduleFailure(result));
128
+ }
129
+
96
130
  export async function runRoutinesRuntimeCommand(args: readonly string[], ctx: CommandContext): Promise<void> {
97
131
  const sub = (args[0] ?? 'list').toLowerCase();
98
132
  const routineRegistry = registryFromContext(ctx);
@@ -200,6 +234,10 @@ export async function runRoutinesRuntimeCommand(args: readonly string[], ctx: Co
200
234
  ctx.print(`Marked Agent routine ${routine.id} stale.`);
201
235
  return;
202
236
  }
237
+ if (sub === 'promote' || sub === 'schedule' || sub === 'promote-schedule') {
238
+ await promoteRoutine(args.slice(1), routineRegistry, ctx);
239
+ return;
240
+ }
203
241
  if (sub === 'delete' || sub === 'remove') {
204
242
  const parsed = parseRoutineArgs(args.slice(1));
205
243
  const id = parsed.rest[0];
@@ -215,7 +253,7 @@ export async function runRoutinesRuntimeCommand(args: readonly string[], ctx: Co
215
253
  ctx.print(`Deleted Agent routine ${removed.id}: ${removed.name}`);
216
254
  return;
217
255
  }
218
- ctx.print('Usage: /routines [list|enabled|search|show|create|update|enable|disable|start|review|stale|delete]');
256
+ ctx.print('Usage: /routines [list|enabled|search|show|create|update|enable|disable|start|review|stale|promote|delete]');
219
257
  } catch (error) {
220
258
  printError(ctx, error);
221
259
  }
@@ -226,7 +264,7 @@ export function registerRoutinesRuntimeCommands(registry: CommandRegistry): void
226
264
  name: 'routines',
227
265
  aliases: ['routine'],
228
266
  description: 'Manage local GoodVibes Agent routines',
229
- usage: '[list|enabled|search <query>|show <id>|create --name <name> --description <summary> --steps <steps>|update <id> [--name ...] [--description ...] [--steps ...]|enable <id>|disable <id>|start <id>|review <id>|stale <id> <reason...>|delete <id> --yes]',
267
+ usage: '[list|enabled|search <query>|show <id>|create --name <name> --description <summary> --steps <steps>|update <id> [--name ...] [--description ...] [--steps ...]|enable <id>|disable <id>|start <id>|review <id>|stale <id> <reason...>|promote <id> --cron <expr> --yes|delete <id> --yes]',
230
268
  handler: runRoutinesRuntimeCommand,
231
269
  });
232
270
  }
@@ -4,6 +4,18 @@ import {
4
4
  } from '@pellux/goodvibes-sdk/platform/automation';
5
5
  import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
6
6
  import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
7
+ import { AgentRoutineRegistry } from '../../agent/routine-registry.ts';
8
+ import {
9
+ buildRoutineSchedulePreview,
10
+ formatRoutineScheduleFailure,
11
+ formatRoutineSchedulePreview,
12
+ formatRoutineScheduleSuccess,
13
+ parseRoutineSchedulePromotionArgs,
14
+ promoteRoutineToDaemonSchedule,
15
+ resolveAgentDaemonConnection,
16
+ } from '../../agent/routine-schedule-promotion.ts';
17
+ import type { CommandContext } from '../command-registry.ts';
18
+ import { requireShellPaths } from './runtime-services.ts';
7
19
 
8
20
  function formatSchedule(schedule: AutomationScheduleDefinition): string {
9
21
  switch (schedule.kind) {
@@ -31,35 +43,66 @@ function formatPrompt(job: AutomationJob): string {
31
43
 
32
44
  function printReadOnlyScheduleBoundary(print: (text: string) => void, requestedAction: string): void {
33
45
  print([
34
- 'GoodVibes Agent schedule commands are read-only in this runtime.',
46
+ 'GoodVibes Agent local schedule commands are read-only in this runtime.',
35
47
  ` requested: ${requestedAction}`,
36
48
  ' policy: no local Agent automation jobs, scheduled spawns, or immediate automation runs',
37
49
  ' use: /schedule list',
38
- ' future: mutate schedules through an Agent-safe public daemon route after explicit approval',
50
+ ' daemon route: use /schedule promote-routine <routine> --cron <expr> --yes to create an external daemon schedule explicitly',
39
51
  ].join('\n'));
40
52
  }
41
53
 
54
+ async function promoteRoutineSchedule(args: readonly string[], ctx: CommandContext): Promise<void> {
55
+ const parsed = parseRoutineSchedulePromotionArgs(args);
56
+ if (parsed.errors.length > 0) {
57
+ ctx.print([
58
+ 'Usage: /schedule promote-routine <routine-id> (--cron <expr>|--every <interval>|--at <iso-time>) [--timezone <tz>] [--name <schedule-name>] [--provider <id>] [--model <model>] [--disabled] --yes',
59
+ ...parsed.errors.map((error) => ` ${error}`),
60
+ ].join('\n'));
61
+ return;
62
+ }
63
+ const shellPaths = requireShellPaths(ctx);
64
+ const routine = AgentRoutineRegistry.fromShellPaths(shellPaths).get(parsed.routineId ?? '');
65
+ if (!routine) {
66
+ ctx.print(`Unknown Agent routine: ${parsed.routineId ?? ''}`);
67
+ return;
68
+ }
69
+ const preview = buildRoutineSchedulePreview(routine, parsed);
70
+ if (!parsed.yes) {
71
+ ctx.print(formatRoutineSchedulePreview(preview));
72
+ return;
73
+ }
74
+ const connection = resolveAgentDaemonConnection(ctx.platform.configManager, shellPaths.homeDirectory);
75
+ const result = await promoteRoutineToDaemonSchedule(connection, preview);
76
+ ctx.print(result.ok ? formatRoutineScheduleSuccess(result) : formatRoutineScheduleFailure(result));
77
+ }
78
+
42
79
  export function registerScheduleRuntimeCommands(registry: CommandRegistry): void {
43
80
  registry.register({
44
81
  name: 'schedule',
45
82
  aliases: ['sched'],
46
- description: 'Inspect automation jobs and scheduled runs',
47
- usage: 'list',
48
- argsHint: 'list',
83
+ description: 'Inspect schedules and explicitly promote local Agent routines to daemon schedules',
84
+ usage: 'list | promote-routine <routine-id> --cron <expr> --yes',
85
+ argsHint: 'list | promote-routine <routine-id> --cron <expr> --yes',
49
86
  async handler(args, ctx) {
87
+ const sub = args[0];
88
+
89
+ if (sub === 'promote-routine' || sub === 'promote' || sub === 'create-routine-schedule') {
90
+ await promoteRoutineSchedule(args.slice(1), ctx);
91
+ return;
92
+ }
93
+
50
94
  const manager = ctx.ops.automationManager;
51
95
  if (!manager) {
52
96
  ctx.print('Automation manager is not available in this runtime.');
53
97
  return;
54
98
  }
55
- const sub = args[0];
56
99
 
57
100
  if (!sub || sub === 'list') {
58
101
  const jobs = manager.listJobs();
59
102
  if (jobs.length === 0) {
60
103
  ctx.print(
61
104
  'No automation jobs.\n'
62
- + 'Agent schedule commands are read-only here; create/run/enable/disable/remove are blocked until an Agent-safe route exists.'
105
+ + 'Local add/run/enable/disable/remove are blocked. Use /schedule promote-routine <routine> --cron <expr> --yes for an explicit external daemon schedule.'
63
106
  );
64
107
  return;
65
108
  }
@@ -84,7 +127,8 @@ export function registerScheduleRuntimeCommands(registry: CommandRegistry): void
84
127
  ctx.print(
85
128
  'Usage:\n'
86
129
  + ' /schedule list\n'
87
- + ' Agent schedule mutations and runs are blocked until an Agent-safe route exists.'
130
+ + ' /schedule promote-routine <routine-id> (--cron <expr>|--every <interval>|--at <iso-time>) --yes\n'
131
+ + ' Local schedule mutations and runs remain blocked.'
88
132
  );
89
133
  },
90
134
  });
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.1.36';
9
+ let _version = '0.1.37';
10
10
  let _sdkVersion = '0.33.35';
11
11
  try {
12
12
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8')) as {