@jc_stack/ez-agents 0.1.0-beta.24 → 0.1.0-beta.26

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
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.26
4
+
5
+ - Start local software-maintenance wakeups in independent ephemeral sessions,
6
+ including after a relay restart, without trying to resume or persist a
7
+ normal owner conversation. This prevents successful upgrades from producing
8
+ false "failed to start" notifications across direct relays.
9
+
10
+ ## 0.1.0-beta.25
11
+
12
+ - Enable Codex Luna `max` reasoning for durable work, schedules and deferred
13
+ owner handoffs. New durable work defaults to Luna/max while responsive chat
14
+ remains Sol/medium.
15
+ - Preserve the strict cap for every other model and expose Luna/max from the
16
+ installed model catalog.
17
+ - Persist Luna/max defaults in rollback-readable form; launch-time policy still
18
+ resolves Luna without an explicit effort to `max`.
19
+
3
20
  ## 0.1.0-beta.24
4
21
 
5
22
  - Preserve host-owned private plugin networks across generated Compose services,
@@ -39,7 +39,7 @@ opaque wrappers remain explicitly “client default”. No credentials are store
39
39
  no inference runs, no new dependency, and no cross-CLI session transfer.
40
40
 
41
41
  New Codex CLI and desktop agents seed **Responsive chat** (`gpt-5.6-sol`,
42
- `medium`). Durable work keeps its independent `gpt-5.6-terra` / `high` default;
42
+ `medium`). Durable work keeps its independent `gpt-5.6-luna` / `max` default;
43
43
  workers can explicitly select another model and effort for the job. Native
44
44
  choices for other executors remain unchanged. No fallback is selected when a
45
45
  model is unavailable.
package/docs/releasing.md CHANGED
@@ -17,8 +17,9 @@ separate release bot is not required.
17
17
  3. Build both Docker targets from a clean checkout:
18
18
  `docker build --target test -t ez-release-tests .` and
19
19
  `docker build --target runtime -t ez-release-runtime .`.
20
- Main: `EZ_RELAY_IMAGE=ez-release-runtime node docker/smoke.mjs`.
21
- WhatsApp: `EZ_WHATSAPP_IMAGE=ez-release-runtime node docker/smoke.mjs`.
20
+ Main relay: `EZ_RELAY_IMAGE=ez-release-runtime node docker/smoke.mjs`.
21
+ The WhatsApp Compose overlay has no separate relay image; verify its
22
+ provider integration with the synthetic plugin smoke in `docs/plugins.md`.
22
23
  4. Create an artifact with `npm pack --ignore-scripts`. Inspect its file list,
23
24
  hash it, extract into a fresh directory, copy `docker/pnpm-lock.yaml` to
24
25
  `pnpm-lock.yaml`, and run `pnpm install --frozen-lockfile`.
@@ -8,12 +8,12 @@ the handoff is saved, instead of waiting for the worker. This is agent guidance,
8
8
  not a keyword classifier, automatic acknowledgement, or latency guarantee.
9
9
 
10
10
  New Codex agents use Sol / medium for chat. Scheduled work defaults independently
11
- to Terra / high; the agent can choose another model and effort for complex work.
12
- `ezenciel-agents-schedule create --now --text-file FILE --model MODEL --effort high`
11
+ to Luna / max; the agent can choose another model and effort for complex work.
12
+ `ezenciel-agents-schedule create --now --text-file FILE --model MODEL --effort max`
13
13
  uses the existing scheduler (include `--name` for a useful task label). Busy owner
14
14
  reply sessions expose the same independent model/effort choice through `defer`.
15
15
  Its retry returns the first saved schedule; changing arguments does not revise
16
- an accepted job. `xhigh` is available only for Luna; other models remain capped at high.
16
+ an accepted job. `xhigh` and `max` are available only for Luna; other models remain capped at high.
17
17
 
18
18
  A handoff includes the objective, relevant context and paths, constraints,
19
19
  authorized actions, acceptance checks and delivery destination. Background
@@ -33,11 +33,11 @@ times fire once, at the earlier instant. Search is bounded to eight years.
33
33
  Public-holiday calendars and arbitrary RRULE syntax are not implemented.
34
34
 
35
35
  New tasks, including work deferred by a busy reply session, default to Codex
36
- `gpt-5.6-terra` with `high` reasoning independently of the creating chat.
36
+ `gpt-5.6-luna` with `max` reasoning independently of the creating chat.
37
37
  New chats use the separate Sol/medium preset. Busy reply `defer` accepts optional
38
38
  `model` and `effort` fields; retries preserve the first saved task choice.
39
- Use `--cli`, `--model`, and `--effort` to specify another choice. `xhigh` is
40
- available only with Codex `gpt-5.6-luna`; every other model remains capped at
39
+ Use `--cli`, `--model`, and `--effort` to specify another choice. `xhigh` and
40
+ `max` are available only with Codex `gpt-5.6-luna`; every other model remains capped at
41
41
  `high`. Non-Codex adapters inherit native effort when unset. Editing preserves the existing AI
42
42
  choice unless those flags override it. Stored choices are checked again at
43
43
  launch, including schedules saved before a policy change.
package/docs/setup.md CHANGED
@@ -56,17 +56,17 @@ New agents using `codex` or `codex-gui` start with `gpt-5.6-sol` and `medium`
56
56
  reasoning, including when initialized with `ezenciel-agents-setup init`. This
57
57
  Ez default takes precedence over discovered host client defaults. Saved agent
58
58
  selections permitted by the current policy are preserved; use the AI settings to choose another model or effort.
59
- Ez accepts `xhigh` only for `gpt-5.6-luna`; every other model rejects explicit
59
+ Ez accepts `xhigh` and `max` only for `gpt-5.6-luna`; every other model rejects explicit
60
60
  reasoning above `high` at selection and execution, including old saved or queued choices.
61
61
  Unset Codex model/effort resolves to
62
- Terra/high at launch. This governs Ez-managed launches; it is not an account-wide
62
+ Luna/max at launch. This governs Ez-managed launches; it is not an account-wide
63
63
  limit on independently launched native clients or executor-created native subagents.
64
64
  Other adapters inherit their native effort when none is selected in Ez. That
65
65
  inherited configuration is not capped by Ez; explicit above-high Ez selections
66
- are rejected unless they are Codex Luna/xhigh. Conversation presets pin Sol/medium; the lower-level
67
- Codex fallback remains Terra/high for work without an explicit choice.
66
+ are rejected unless they are Codex Luna/xhigh or Luna/max. Conversation presets pin Sol/medium; the lower-level
67
+ Codex fallback is Luna/max for work without an explicit choice.
68
68
 
69
- New scheduled and one-off background tasks default to Codex Terra/high independently
69
+ New scheduled and one-off background tasks default to Codex Luna/max independently
70
70
  of the creating chat. Use scheduler `--cli`, `--model`, and `--effort` flags for
71
71
  explicit overrides. Editing a schedule preserves its settings unless overridden.
72
72
  Restricted messaging tasks use Sol/medium while retaining their approved tool
package/docs/upgrades.md CHANGED
@@ -185,6 +185,12 @@ written by the new one. Increment it for incompatible writes; this updater will
185
185
  refuse that migration. `mainProtocol` identifies the supported updater/registry
186
186
  contract, currently 1. Plugin package and manifest versions must match.
187
187
 
188
+ The Luna/max durable default keeps this contract: persisted Codex Luna presets
189
+ omit the effort field so older runtimes can read and fail over to their native
190
+ safe default, while the current launcher resolves an omitted Luna effort to
191
+ `max`. The execution choice, rather than the storage encoding, is the policy
192
+ surface.
193
+
188
194
  Verify upgrade from the previous supported artifact, retained identity/state,
189
195
  failed-health rollback, and rejection of incompatible candidates. Main runtime
190
196
  changes also need supervisor restart and requesting-process-exit tests. Run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.24",
3
+ "version": "0.1.0-beta.26",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
package/src/ai.ts CHANGED
@@ -21,7 +21,9 @@ export const isExecutionChoice = (v: unknown): v is ExecutionChoice => {
21
21
  const c = v as ExecutionChoice | undefined
22
22
  return Boolean(c && /^[0-9a-f-]{36}$/i.test(c.sessionId) && isPreset(c.preset))
23
23
  }
24
- export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || 'default effort'}`
24
+ export const presetLabel = (p: AiPreset) => `${p.cli} · ${p.model || 'client default'} · ${p.effort || (
25
+ ['codex', 'codex-gui'].includes(p.cli) && p.model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'default effort'
26
+ )}`
25
27
  // The seed delegates model selection to the native client. Project its resolved
26
28
  // settings for status without pinning future conversations to that snapshot.
27
29
  export const statusPreset = (preset: AiPreset, discovered: AiPreset[]): AiPreset =>
@@ -39,6 +41,14 @@ export const initialPreset = (cli: string): AiPreset => {
39
41
  }
40
42
  }
41
43
 
44
+ // Keep persisted state readable by older releases. Luna/max is an execution
45
+ // default; the launcher resolves an omitted Luna effort back to max.
46
+ export const persistedPreset = (preset: AiPreset): AiPreset => {
47
+ if (!['codex', 'codex-gui'].includes(preset.cli) || preset.model !== 'gpt-5.6-luna' || preset.effort !== 'max') return preset
48
+ const { effort: _effort, ...rollbackReadable } = preset
49
+ return rollbackReadable
50
+ }
51
+
42
52
  // Conversation defaults are independent of durable work and explicit saved choices.
43
53
  export const chatPreset = (cli: string): AiPreset => {
44
54
  const preset = initialPreset(cli)
@@ -1,7 +1,7 @@
1
1
  import { assertEffort } from './model-policy.js'
2
2
  import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
- import { isPreset, type AiPreset, type ExecutionChoice } from './ai.js'
4
+ import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice } from './ai.js'
5
5
 
6
6
  export type Owner = {
7
7
  kind?: 'group'
@@ -94,6 +94,7 @@ export class ControlStore {
94
94
  try {
95
95
  const parsed: unknown = JSON.parse(await readFile(this.statePath, 'utf8'))
96
96
  if (!isState(parsed)) throw new Error('Control state has an unsupported shape')
97
+ if (parsed.ai) parsed.ai.presets = parsed.ai.presets.map(persistedPreset)
97
98
  return parsed
98
99
  } catch (error: unknown) {
99
100
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
@@ -261,7 +262,7 @@ export class ControlStore {
261
262
  async aiState(initial: AiPreset) {
262
263
  return this.withLock(async () => {
263
264
  const state = await this.readState()
264
- state.ai ??= { presets: [initial], defaultId: initial.id, selectedId: initial.id }
265
+ state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
265
266
  await this.writeState(state)
266
267
  return state.ai
267
268
  })
@@ -273,13 +274,13 @@ export class ControlStore {
273
274
  const state = await this.readState()
274
275
  const first = initial.cli === 'codex' || initial.cli === 'codex-gui'
275
276
  ? initial : discovered.find((p) => p.cli === initial.cli) ?? initial
276
- state.ai ??= { presets: [first], defaultId: first.id, selectedId: first.id }
277
+ state.ai ??= { presets: [persistedPreset(first)], defaultId: first.id, selectedId: first.id }
277
278
  const ai = state.ai
278
279
  // Refresh discovery entries, but never rewrite an active/default or user-saved choice.
279
280
  const preserved = ai.presets.filter((p) => !p.id.startsWith('detected_') ||
280
- p.id === ai.selectedId || p.id === ai.defaultId)
281
- ai.presets = [...preserved, ...discovered.filter((p) => !preserved.some((old) => old.id === p.id))]
282
- if (initial.id === 'chat-default' && !ai.presets.some(p => p.id === initial.id)) ai.presets.push(initial)
281
+ p.id === ai.selectedId || p.id === ai.defaultId).map(persistedPreset)
282
+ ai.presets = [...preserved, ...discovered.map(persistedPreset).filter((p) => !preserved.some((old) => old.id === p.id))]
283
+ if (initial.id === 'chat-default' && !ai.presets.some(p => p.id === initial.id)) ai.presets.push(persistedPreset(initial))
283
284
  await this.writeState(state)
284
285
  })
285
286
  }
@@ -287,7 +288,7 @@ export class ControlStore {
287
288
  async captureChoice(initial: AiPreset): Promise<ExecutionChoice> {
288
289
  return this.withLock(async () => {
289
290
  const state = await this.readState()
290
- state.ai ??= { presets: [initial], defaultId: initial.id, selectedId: initial.id }
291
+ state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
291
292
  const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
292
293
  state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
293
294
  if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
@@ -331,7 +332,7 @@ export class ControlStore {
331
332
  if (!state.ai) throw new Error('AI settings not initialized')
332
333
  if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
333
334
  throw new Error('Keep it small: at most 12 saved AIs.')
334
- state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), preset]
335
+ state.ai.presets = [...state.ai.presets.filter((p) => p.id !== preset.id), persistedPreset(preset)]
335
336
  await this.writeState(state)
336
337
  })
337
338
  }
package/src/index.ts CHANGED
@@ -31,7 +31,7 @@ import { transcribeAudio, synthesizeSpeech } from './audio.js'
31
31
  import { normalizeReactionEmoji } from './reaction.js'
32
32
  import { downloadTelegramFile } from './read-request.js'
33
33
  import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
34
- import { chatPreset, presetLabel, statusPreset } from './ai.js'
34
+ import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
35
35
  import { discoverDefaults } from './client-defaults.js'
36
36
  import { initializeWorkspace } from './workspace.js'
37
37
  import { softwareStatus } from './software-status.js'
@@ -65,6 +65,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
65
65
  let drainTimer: ReturnType<typeof setInterval> | undefined
66
66
  const codexHome = join(config.controlDir, 'cli', 'codex')
67
67
  const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
68
+ const durableWorkerChoice = () => ({ sessionId: randomUUID(), preset: persistedPreset(initialPreset('codex')) })
68
69
  const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
69
70
  const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
70
71
  ? new PagerDutyStocksMonitor({
@@ -192,7 +193,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
192
193
  const launchStarted = performance.now()
193
194
  const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
194
195
  if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
195
- const session = run.external || run.taskId || run.scheduled || run.replyOnly
196
+ const maintenanceWakeup = run.id.startsWith('r_update_')
197
+ const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || run.replyOnly || maintenanceWakeup)
198
+ const session = startsOwnSession
196
199
  ? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
197
200
  : await control.executionSession(started.execution!)
198
201
  const selected = run.taskId ? chatPreset('codex') : started.execution!.preset
@@ -210,7 +213,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
210
213
  sessionId: session.nativeSessionId || session.sessionId,
211
214
  isResume: session.hasStarted,
212
215
  eventSource: run.external?.sourceId,
213
- onSession: run.external || run.taskId || run.replyOnly ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
216
+ onSession: run.external || run.taskId || run.replyOnly || maintenanceWakeup ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
214
217
  })
215
218
  const executionStarted = performance.now()
216
219
  // Attach before disk writes: a fast child can close while PID persistence
@@ -310,7 +313,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
310
313
  for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
311
314
  try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
312
315
  }
313
- await queueUpdateAttention(config.controlDir,owner,runs,await control.captureChoice(aiMenu.initial))
316
+ await queueUpdateAttention(config.controlDir,owner,runs,durableWorkerChoice())
314
317
  }
315
318
  for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
316
319
  try {
@@ -327,7 +330,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
327
330
  await runs.create({
328
331
  taskId: task?.id,
329
332
  id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
330
- texts: [], execution: await control.captureChoice(aiMenu.initial),
333
+ texts: [], execution: durableWorkerChoice(),
331
334
  external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
332
335
  })
333
336
  }
@@ -1,18 +1,33 @@
1
1
  export const CODEX_CHAT_MODEL = 'gpt-5.6-sol'
2
2
  export const CHAT_EFFORT = 'medium'
3
- export const CODEX_DEFAULT_MODEL = 'gpt-5.6-terra'
4
- export const DEFAULT_EFFORT = 'high'
3
+ export const CODEX_DEFAULT_MODEL = 'gpt-5.6-luna'
4
+ export const DEFAULT_EFFORT = 'max'
5
5
  export const allowedEffort = (effort?: string, model?: string, cli?: string) => effort === undefined ||
6
6
  ['none', 'minimal', 'low', 'medium', 'high'].includes(effort) ||
7
- (effort === 'xhigh' && model === 'gpt-5.6-luna' && ['codex', 'codex-gui'].includes(cli || ''))
7
+ (['xhigh', 'max'].includes(effort) && model === 'gpt-5.6-luna' && ['codex', 'codex-gui'].includes(cli || ''))
8
8
  export function assertEffort(effort?: string, model?: string, cli?: string) {
9
- if (!allowedEffort(effort, model, cli)) throw new Error('Reasoning effort is capped at high, except Codex Luna/xhigh; choose none, minimal, low, medium, high, or xhigh with gpt-5.6-luna.')
9
+ if (!allowedEffort(effort, model, cli)) throw new Error('Reasoning effort is capped at high, except Codex Luna/xhigh/max; choose none, minimal, low, medium, high, xhigh, or max with gpt-5.6-luna.')
10
10
  }
11
11
  export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
12
12
  assertEffort(options.effort, options.model, cli)
13
+ const model = options.model || CODEX_DEFAULT_MODEL
13
14
  return { ...options,
14
- ...(['codex', 'codex-gui'].includes(cli) ? { model: options.model || CODEX_DEFAULT_MODEL } : {}),
15
+ ...(['codex', 'codex-gui'].includes(cli) ? { model } : {}),
15
16
  ...(['codex', 'codex-gui'].includes(cli)
16
- ? { effort: options.effort || DEFAULT_EFFORT } : {}),
17
+ ? { effort: options.effort || (model === 'gpt-5.6-luna' ? DEFAULT_EFFORT : 'high') } : {}),
17
18
  }
18
19
  }
20
+
21
+ export function executionOverrides<T extends { model?: string; effort?: string }>(
22
+ cli: string,
23
+ base: T,
24
+ model?: string,
25
+ effort?: string,
26
+ ): T {
27
+ const options = {
28
+ ...base,
29
+ ...(model !== undefined ? { model, ...(effort === undefined ? { effort: undefined } : {}) } : {}),
30
+ ...(effort !== undefined ? { effort } : {}),
31
+ } as T
32
+ return executionDefaults(cli, options)
33
+ }
@@ -1,4 +1,4 @@
1
- import { assertEffort } from './model-policy.js'
1
+ import { executionOverrides } from './model-policy.js'
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { initialPreset, isPreset } from './ai.js'
4
4
  import { readFile, readdir, lstat } from 'node:fs/promises'
@@ -40,9 +40,8 @@ export async function replyCall(controlDir: string, runId: string, workspace: st
40
40
  if (name === 'send') return runs.enqueueMessage(runId, args.text, { id: `${runId}_busy_reply`, replyToMessageId: run.messageId })
41
41
  if (name === 'defer') {
42
42
  if (!run.execution) throw new Error('Missing execution choice')
43
- const preset = { ...initialPreset('codex'), ...(args.model !== undefined ? { model: args.model } : {}), ...(args.effort !== undefined ? { effort: args.effort } : {}) }
43
+ const preset = executionOverrides('codex', initialPreset('codex'), args.model as string | undefined, args.effort as string | undefined)
44
44
  if (!isPreset(preset)) throw new Error('Invalid worker model or effort')
45
- assertEffort(preset.effort, preset.model, preset.cli)
46
45
  const owner = (await new ControlStore(controlDir, 900000).status()).owner!
47
46
  const scheduler = new Scheduler(controlDir), id = `s_reply_${runId}`
48
47
  try { return { id: (await scheduler.get(id)).id } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
package/src/reply-mcp.ts CHANGED
@@ -3,7 +3,7 @@ import { replyCall } from './reply-context.js'
3
3
  const [controlDir, runId, workspace] = process.argv.slice(2)
4
4
  const tools = [
5
5
  { name: 'context', description: 'Read this owner request, recent conversation, active and historical runs, and task progress.', inputSchema: { type: 'object', properties: {}, additionalProperties: false } },
6
- ...['send', 'defer'].map(name => ({ name, description: name === 'send' ? 'Send one answer to the paired owner. Repeated calls reuse the same receipt.' : 'Queue the current owner request for a writer session. Include needed context and acceptance checks in text. Optional model and effort select the worker independently; defaults are gpt-5.6-terra/high. Repeated calls return the same schedule.', inputSchema: { type: 'object', properties: { text: { type: 'string', maxLength: 8000 }, ...(name === 'defer' ? { model: { type: 'string', maxLength: 160 }, effort: { type: 'string', enum: ['none', 'minimal', 'low', 'medium', 'high'] } } : {}) }, required: ['text'], additionalProperties: false } })),
6
+ ...['send', 'defer'].map(name => ({ name, description: name === 'send' ? 'Send one answer to the paired owner. Repeated calls reuse the same receipt.' : 'Queue the current owner request for a writer session. Include needed context and acceptance checks in text. Optional model and effort select the worker independently; defaults are gpt-5.6-luna/max. Repeated calls return the same schedule.', inputSchema: { type: 'object', properties: { text: { type: 'string', maxLength: 8000 }, ...(name === 'defer' ? { model: { type: 'string', maxLength: 160 }, effort: { type: 'string', enum: ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] } } : {}) }, required: ['text'], additionalProperties: false } })),
7
7
  ]
8
8
  for await (const line of createInterface({ input: process.stdin })) {
9
9
  let request: any
@@ -6,6 +6,7 @@ import { loadControlConfig } from './config.js'
6
6
  import { ControlStore } from './control-state.js'
7
7
  import { RunStore } from './runs.js'
8
8
  import { initialPreset, isPreset } from './ai.js'
9
+ import { executionOverrides } from './model-policy.js'
9
10
  import { Scheduler } from './scheduler.js'
10
11
  import { ownsRun } from './identity.js'
11
12
  import { nextOccurrence, type Trigger } from './schedule-time.js'
@@ -22,11 +23,11 @@ async function main() {
22
23
  review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
23
24
  create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
24
25
  --now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
25
- [--cli EXECUTOR] [--model MODEL] [--effort none|minimal|low|medium|high|xhigh (Luna only)]
26
+ [--cli EXECUTOR] [--model MODEL] [--effort none|minimal|low|medium|high|xhigh|max (Luna only)]
26
27
  [--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
27
28
  Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
28
29
  A conditional review schedule consumes no model run when there are no unreviewed failures.
29
- New tasks default to Codex Terra/high, independently of the current chat. Explicit settings override these defaults; edit preserves existing settings unless overridden.
30
+ New tasks default to Codex Luna/max, independently of the current chat. Explicit settings override these defaults; edit preserves existing settings unless overridden.
30
31
  Creates a durable, asynchronous CLI task. Instructions are text, never shell commands.
31
32
  Use --now to delegate long work and return to chat. Run completion is not delivery proof.
32
33
  Edit replaces the full schedule. Pause/remove affect future work; cancel stops a particular run.
@@ -73,7 +74,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
73
74
  v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
74
75
  const previous = action === 'edit' ? (await scheduler.get(id!)).execution : undefined
75
76
  const base = v.cli ? initialPreset(v.cli) : previous?.preset || initialPreset('codex')
76
- const preset = {...base, ...(v.model ? {model:v.model} : {}), ...(v.effort ? {effort:v.effort} : {})}
77
+ const preset = executionOverrides(base.cli, base, v.model, v.effort)
77
78
  if (!isPreset(preset)) throw new Error('Invalid task AI selection')
78
79
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
79
80
  text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
package/src/scheduler.ts CHANGED
@@ -5,7 +5,7 @@ import { randomUUID, createHash } from 'node:crypto'
5
5
  import { join } from 'node:path'
6
6
  import type { Owner } from './control-state.js'
7
7
  import { assertId, ownsRun } from './identity.js'
8
- import { type ExecutionChoice, isExecutionChoice } from './ai.js'
8
+ import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
9
9
  import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
10
10
  import { RunStore, type RunRecord } from './runs.js'
11
11
 
@@ -64,9 +64,10 @@ export class Scheduler {
64
64
  async save(input: Omit<Schedule,'version'|'revision'>, exclusive = false): Promise<Schedule> {
65
65
  await this.ensure(); assertId(input.id)
66
66
  if (input.when !== undefined && input.when !== 'unreviewed-failures') throw new Error('Unknown schedule condition')
67
- if (!input.name || !input.text?.trim() || !isExecutionChoice(input.execution)) throw new Error('Schedule needs name, text and an AI selection')
68
- assertEffort(input.execution.preset.effort, input.execution.preset.model, input.execution.preset.cli)
69
- const s: Schedule = {...input,trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
67
+ const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
68
+ if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
69
+ assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
70
+ const s: Schedule = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
70
71
  if (nextOccurrence(s.trigger,Date.now()-1) === null) throw new Error('Schedule has no future occurrence within eight years')
71
72
  await atomic(join(this.dir,s.id+'.json'),s,exclusive)
72
73
  return s
package/test/ai.test.ts CHANGED
@@ -8,6 +8,7 @@ import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
8
8
  import { createAiMenu } from '../src/menu.js'
9
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
10
10
  import { InboxStore } from '../src/inbox.js'
11
+ import { executionDefaults } from '../src/model-policy.js'
11
12
  import type { Update } from 'grammy/types'
12
13
 
13
14
  test('AI choices pin model, effort and session; defaults and CLI switches do not reroute old work', async () => {
@@ -87,7 +88,7 @@ test('model catalog projects native metadata only, excluding hidden entries and
87
88
  ] }))
88
89
  assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
89
90
  { cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
90
- { cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh'] },
91
+ { cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh', 'max'] },
91
92
  ])
92
93
  assert.equal(isPreset({ id: 'x', name: 'x', cli: 'grok', model: '--shell escape' }), false)
93
94
  } finally { await rm(home, { recursive: true, force: true }) }
@@ -165,7 +166,7 @@ test('native executor flags carry the exact model and effort; only structured me
165
166
  })
166
167
 
167
168
  for (const cli of ['codex', 'codex-gui']) {
168
- test(`${cli} initializes Terra high ahead of host defaults and preserves saved choices`, async () => {
169
+ test(`${cli} initializes Luna max ahead of host defaults and preserves saved choices`, async () => {
169
170
  const dir = await mkdtemp(join(tmpdir(), 'ez-ai-default-'))
170
171
  try {
171
172
  const store = new ControlStore(dir, 1000)
@@ -174,8 +175,9 @@ for (const cli of ['codex', 'codex-gui']) {
174
175
  model: 'host-model', effort: 'low' }]
175
176
  await store.syncClientPresets(initial, discovered)
176
177
  const first = await store.captureChoice(initial)
177
- assert.equal(first.preset.model, 'gpt-5.6-terra')
178
- assert.equal(first.preset.effort, 'high')
178
+ assert.equal(first.preset.model, 'gpt-5.6-luna')
179
+ assert.equal(first.preset.effort, undefined)
180
+ assert.equal(executionDefaults(cli, first.preset).effort, 'max')
179
181
  assert.equal(first.preset.cli, cli)
180
182
  const saved = { id: 'custom', name: 'Custom', cli, model: 'custom-model', effort: 'medium' }
181
183
  await store.savePreset(saved)
@@ -197,8 +199,8 @@ for (const cli of ['codex', 'codex-gui']) {
197
199
  const chat = await store.captureChoice(chatPreset(cli))
198
200
  assert.equal(chat.preset.model, 'gpt-5.6-sol')
199
201
  assert.equal(chat.preset.effort, 'medium')
200
- assert.equal(initialPreset(cli).model, 'gpt-5.6-terra')
201
- assert.equal(initialPreset(cli).effort, 'high')
202
+ assert.equal(initialPreset(cli).model, 'gpt-5.6-luna')
203
+ assert.equal(initialPreset(cli).effort, 'max')
202
204
  const old = initialPreset(cli)
203
205
  await store.savePreset(old)
204
206
  await store.defaultPreset(old.id)
@@ -70,7 +70,7 @@ test('status projects the native client default without pinning the seed', () =>
70
70
  assert.deepEqual(statusPreset({ ...initial, id: 'detected_empty' }, [discovered]), discovered)
71
71
  const explicit = { ...discovered, id: 'saved', model: 'chosen-codex', effort: 'high' }
72
72
  assert.equal(statusPreset(explicit, [discovered]), explicit)
73
- assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model, 'gpt-5.6-terra')
73
+ assert.equal(statusPreset(initialPreset('codex-gui'), [{ ...discovered, cli: 'codex-gui' }]).model, 'gpt-5.6-luna')
74
74
  })
75
75
 
76
76
  test('seed uses the configured executor; repeated refresh preserves current/default and queued snapshots', async () => {
@@ -3,10 +3,10 @@ import assert from 'node:assert/strict'
3
3
  import { mkdtemp, rm } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
- import { executionDefaults } from '../src/model-policy.js'
6
+ import { executionDefaults, executionOverrides } from '../src/model-policy.js'
7
7
  import { startExecutorJob } from '../src/executor.js'
8
8
  import { ControlStore } from '../src/control-state.js'
9
- import { initialPreset, readModels, validateSelection } from '../src/ai.js'
9
+ import { initialPreset, persistedPreset, readModels, validateSelection } from '../src/ai.js'
10
10
  import { taskArguments } from '../src/task-executor.js'
11
11
  import { runCodexSession } from '../src/codex-session.js'
12
12
  import { runDesktopTurn } from '../src/desktop-bridge.js'
@@ -23,24 +23,34 @@ test('all non-Luna model selections and launches reject effort above high before
23
23
  await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
24
24
  })
25
25
 
26
- test('Codex Luna accepts xhigh while every other model and CLI remains capped', async () => {
27
- const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort:'xhigh' }
28
- await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh'] }], async () => true)
29
- assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort:'xhigh' }), { model:'gpt-5.6-luna', effort:'xhigh' })
30
- assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort:'xhigh' }), /capped at high/)
31
- assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort:'xhigh' }), /capped at high/)
26
+ test('Codex Luna accepts xhigh and max while every other model and CLI remains capped', async () => {
27
+ for (const effort of ['xhigh', 'max']) {
28
+ const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort }
29
+ await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh','max'] }], async () => true)
30
+ assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort }), { model:'gpt-5.6-luna', effort })
31
+ assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort }), /capped at high/)
32
+ assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort }), /capped at high/)
33
+ }
32
34
  })
33
35
 
34
- test('restricted tasks pin Terra high, preserve explicit choices and reject higher effort', () => {
36
+ test('restricted tasks pin Luna max, preserve explicit choices and reject higher effort', () => {
35
37
  const args = taskArguments('/unused', ['broker'], 'prompt')
36
- assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-terra')
37
- assert.ok(args.includes('model_reasoning_effort="high"'))
38
+ assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-luna')
39
+ assert.ok(args.includes('model_reasoning_effort="max"'))
38
40
  const custom = taskArguments('/unused', ['broker'], 'prompt', undefined, {model:'custom-model',effort:'low'})
39
41
  assert.equal(custom[custom.indexOf('--model')+1], 'custom-model')
40
42
  assert.ok(custom.includes('model_reasoning_effort="low"'))
41
43
  assert.throws(() => taskArguments('/unused', ['broker'], 'prompt', undefined, {effort:'xhigh'}), /capped at high/)
42
44
  assert.deepEqual(executionDefaults('codex', {model:'custom-model',effort:'medium'}), {model:'custom-model',effort:'medium'})
43
- assert.deepEqual(executionDefaults('codex', {}), {model:'gpt-5.6-terra',effort:'high'})
45
+ assert.deepEqual(executionDefaults('codex', {model:'gpt-6-astra'}), {model:'gpt-6-astra',effort:'high'})
46
+ assert.deepEqual(executionDefaults('codex', {model:'gpt-5.6-terra'}), {model:'gpt-5.6-terra',effort:'high'})
47
+ assert.deepEqual(executionDefaults('codex', {model:'gpt-5.6-luna'}), {model:'gpt-5.6-luna',effort:'max'})
48
+ assert.deepEqual(executionDefaults('codex', {}), {model:'gpt-5.6-luna',effort:'max'})
49
+ assert.deepEqual(executionOverrides('codex', {model:'gpt-5.6-luna',effort:'max'}, 'gpt-6-astra'), {model:'gpt-6-astra',effort:'high'})
50
+ assert.deepEqual(executionOverrides('codex', {model:'gpt-5.6-luna',effort:'max'}, 'gpt-5.6-luna'), {model:'gpt-5.6-luna',effort:'max'})
51
+ const stored = persistedPreset({id:'luna',name:'Luna',cli:'codex',model:'gpt-5.6-luna',effort:'max'})
52
+ assert.equal(stored.effort, undefined)
53
+ assert.deepEqual(executionDefaults('codex', stored), {id:'luna',name:'Luna',cli:'codex',model:'gpt-5.6-luna',effort:'max'})
44
54
  })
45
55
 
46
56
  test('preset persistence rejects above-high choices without changing current settings', async () => {
@@ -44,7 +44,7 @@ test('reply native adapter exposes only context send defer with shell and networ
44
44
  assert.doesNotMatch(args,/--add-dir/)
45
45
  })
46
46
 
47
- test('reply handoff deduplicates the owner request and defaults independently to Terra high', async () => {
47
+ test('reply handoff deduplicates the owner request and defaults independently to Luna max', async () => {
48
48
  const root=await mkdtemp(join(tmpdir(),'ez-reply-defer-')), runs=new RunStore(root)
49
49
  try {
50
50
  const control=new ControlStore(root,900000)
@@ -55,11 +55,17 @@ test('reply handoff deduplicates the owner request and defaults independently to
55
55
  const first=await replyCall(root,'tg_4',root,'defer',{text:'Prepare the report using the canonical sources'})
56
56
  assert.deepEqual(await replyCall(root,'tg_4',root,'defer',{text:'retry'}),first)
57
57
  const saved=JSON.parse(await readFile(join(root,'schedules','s_reply_tg_4.json'),'utf8'))
58
- assert.equal(saved.execution.preset.model,'gpt-5.6-terra')
59
- assert.equal(saved.execution.preset.effort,'high')
58
+ assert.equal(saved.execution.preset.model,'gpt-5.6-luna')
59
+ assert.equal(saved.execution.preset.effort,undefined)
60
60
  assert.notEqual(saved.execution.sessionId,execution.sessionId)
61
61
  assert.match(saved.text,/Make the report/)
62
62
  assert.equal(saved.owner.telegramChatId,101)
63
+ await runs.create({id:'tg_11',chatId:101,telegramUserId:101,texts:['Use Astra'],execution})
64
+ await runs.patch('tg_11',{status:'running',replyOnly:true})
65
+ await replyCall(root,'tg_11',root,'defer',{text:'Use Astra for this worker',model:'gpt-6-astra'})
66
+ const astra=JSON.parse(await readFile(join(root,'schedules','s_reply_tg_11.json'),'utf8'))
67
+ assert.equal(astra.execution.preset.model,'gpt-6-astra')
68
+ assert.equal(astra.execution.preset.effort,'high')
63
69
  }finally{await rm(root,{recursive:true,force:true})}
64
70
  })
65
71
 
@@ -22,10 +22,12 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
22
22
  const args=['create','test','--at','2027-09-09T09:00:00+04:00','--text','Literal $(do-not-execute) /goal objective']
23
23
  const saved=JSON.parse((await exec(process.execPath,[bin,...args],{env})).stdout)
24
24
  assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'codex')
25
- assert.equal(saved.execution.preset.model,'gpt-5.6-terra');assert.equal(saved.execution.preset.effort,'high')
26
- await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--effort','xhigh'],{env}),/capped at high/)
27
- const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna xhigh task','--model','gpt-5.6-luna','--effort','xhigh'],{env})).stdout)
28
- assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,'xhigh')
25
+ assert.equal(saved.execution.preset.model,'gpt-5.6-luna');assert.equal(saved.execution.preset.effort,undefined)
26
+ await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--model','gpt-5.6-terra','--effort','xhigh'],{env}),/capped at high/)
27
+ const astra=JSON.parse((await exec(process.execPath,[bin,'create','astra','--at','2027-09-09T10:00:00+04:00','--text','Astra task','--model','gpt-6-astra'],{env})).stdout)
28
+ assert.equal(astra.execution.preset.model,'gpt-6-astra');assert.equal(astra.execution.preset.effort,'high')
29
+ const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna max task','--model','gpt-5.6-luna','--effort','max'],{env})).stdout)
30
+ assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,undefined)
29
31
  assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
30
32
  await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
31
33
  assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
@@ -11,8 +11,33 @@ import { ControlStore } from '../src/control-state.js'
11
11
  import { RunStore } from '../src/runs.js'
12
12
  import { Scheduler } from '../src/scheduler.js'
13
13
  import { initialPreset } from '../src/ai.js'
14
+ import { randomUUID } from 'node:crypto'
14
15
 
15
16
  const until=async(check:()=>Promise<boolean>)=>{for(let i=0;i<200;i++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
17
+
18
+ test('maintenance wakeups use an independent ephemeral session after a relay restart',async()=>{
19
+ const dir=await mkdtemp(join(tmpdir(),'ez-maintenance-session-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
20
+ let launchSession='',onSessionCalls=0
21
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(_texts,options)=>{
22
+ launchSession=options.sessionId ?? ''
23
+ if(options.onSession){onSessionCalls++;await options.onSession('native-maintenance')}
24
+ const child=spawn(process.execPath,['-e','setTimeout(()=>{},10)'],{detached:process.platform!=='win32'})
25
+ await once(child,'spawn')
26
+ return {child,cleanup:async()=>{},stdout:''}
27
+ })
28
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof relay.bot.botInfo
29
+ relay.bot.api.config.use(async()=>({ok:true,result:{message_id:42}}) as never)
30
+ try{
31
+ await control.requestPairing(101,101);await control.approveOwner(101)
32
+ const execution={sessionId:randomUUID(),preset:initialPreset('codex')}
33
+ await runs.create({id:'r_update_fixture',chatId:101,telegramUserId:101,texts:['maintenance'],execution})
34
+ await relay.drainSources()
35
+ await until(async()=> (await runs.get('r_update_fixture'))?.status==='completed')
36
+ assert.notEqual(launchSession,execution.sessionId)
37
+ assert.equal(onSessionCalls,0)
38
+ }finally{await relay.stop();await rm(dir,{recursive:true,force:true})}
39
+ })
40
+
16
41
  test('chat replies through the real ingress/outbox while scheduled CLI remains alive; targeted cancellation',async()=>{
17
42
  const dir=await mkdtemp(join(tmpdir(),'ez-scheduler-relay-')), children:ReturnType<typeof spawn>[]=[]
18
43
  const runs=new RunStore(dir),scheduler=new Scheduler(dir),control=new ControlStore(dir,1000)