gestalt-mobile 0.25.2 → 0.25.3

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,255 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ import { orgPlanAgentDisplayName } from '../../../../shared/org-plan-position.js';
7
+ export const blockingResumeConditions = {
8
+ planChange: 'planRevision',
9
+ hardBlock: 'externalStateChanged',
10
+ missingDependency: 'dependencyInstalled',
11
+ permissionRequired: 'permissionGranted',
12
+ externalState: 'externalStateChanged',
13
+ materialAmbiguity: 'userGuidance',
14
+ };
15
+ export function validStructuredBlock(value) {
16
+ return Boolean(value && blockingResumeConditions[value.reason] === value.resumeCondition);
17
+ }
18
+ export function parsePersistedSupervisedLifecycle(value) {
19
+ const root = record(value);
20
+ if (!root)
21
+ return undefined;
22
+ const blockingValue = record(root.blocking);
23
+ const blocking = blockingValue ? parseStructuredBlock(blockingValue) : undefined;
24
+ if (blockingValue && !blocking)
25
+ return undefined;
26
+ const executorValue = record(root.executor);
27
+ if (!executorValue)
28
+ return blocking ? { blocking } : {};
29
+ const processesValue = executorValue.ownedProcesses;
30
+ if (!Array.isArray(processesValue) || processesValue.length > 64)
31
+ return undefined;
32
+ const ownedProcesses = processesValue.flatMap((candidate) => {
33
+ const process = record(candidate);
34
+ if (!process)
35
+ return [];
36
+ const processId = boundedText(process.processId);
37
+ const itemId = boundedText(process.itemId);
38
+ const ownerThreadId = boundedText(process.ownerThreadId);
39
+ const ownerTaskPath = boundedText(process.ownerTaskPath);
40
+ const ownership = stringValue(process.ownership, ['executor', 'supervisor']);
41
+ const state = stringValue(process.state, [
42
+ 'running',
43
+ 'detached-active',
44
+ 'exited-awaiting-result',
45
+ 'result-consumed',
46
+ 'terminated-for-budget',
47
+ ]);
48
+ const observedAt = boundedText(process.observedAt);
49
+ const elapsedMs = nonNegativeNumber(process.elapsedMs);
50
+ const cpuPercent = nullableNonNegativeNumber(process.cpuPercent);
51
+ const rssBytes = nullableNonNegativeNumber(process.rssBytes);
52
+ if (!processId ||
53
+ !itemId ||
54
+ !ownerThreadId ||
55
+ !ownerTaskPath ||
56
+ !ownership ||
57
+ !state ||
58
+ !observedAt ||
59
+ elapsedMs === null ||
60
+ cpuPercent === undefined ||
61
+ rssBytes === undefined)
62
+ return [];
63
+ const osPid = nonNegativeInteger(process.osPid);
64
+ const exitStatus = integer(process.exitStatus);
65
+ const resultArtifact = boundedText(process.resultArtifact);
66
+ return [
67
+ {
68
+ processId,
69
+ itemId,
70
+ ownerThreadId,
71
+ ownerTaskPath,
72
+ ownership,
73
+ state,
74
+ observedAt,
75
+ elapsedMs,
76
+ cpuPercent,
77
+ rssBytes,
78
+ ...(osPid === null ? {} : { osPid }),
79
+ ...(exitStatus === null ? {} : { exitStatus }),
80
+ ...(resultArtifact ? { resultArtifact } : {}),
81
+ },
82
+ ];
83
+ });
84
+ if (ownedProcesses.length !== processesValue.length)
85
+ return undefined;
86
+ const canonicalPosition = boundedText(executorValue.canonicalPosition);
87
+ const canonicalTaskName = boundedText(executorValue.canonicalTaskName);
88
+ const taskPath = boundedText(executorValue.taskPath);
89
+ const threadId = boundedText(executorValue.threadId);
90
+ const l1State = stringValue(executorValue.l1State, ['TODO', 'WIP', 'DONE']);
91
+ const l2State = stringValue(executorValue.l2State, ['TODO', 'WIP', 'DONE']);
92
+ const lastActivityAt = boundedText(executorValue.lastActivityAt);
93
+ const outcome = stringValue(executorValue.outcome, [
94
+ 'objective_complete',
95
+ 'partial',
96
+ 'blocked',
97
+ 'cancelled',
98
+ 'failed',
99
+ ]);
100
+ const continuationGeneration = nonNegativeInteger(executorValue.continuationGeneration);
101
+ const continuationCount = nonNegativeInteger(executorValue.continuationCount);
102
+ if (!canonicalPosition ||
103
+ !canonicalTaskName ||
104
+ !taskPath ||
105
+ !threadId ||
106
+ !l1State ||
107
+ !lastActivityAt ||
108
+ !outcome ||
109
+ continuationGeneration === null ||
110
+ continuationCount === null)
111
+ return undefined;
112
+ const executorBlockingValue = record(executorValue.blocking);
113
+ const executorBlocking = executorBlockingValue
114
+ ? parseStructuredBlock(executorBlockingValue)
115
+ : undefined;
116
+ if (executorBlockingValue && !executorBlocking)
117
+ return undefined;
118
+ return {
119
+ executor: {
120
+ canonicalPosition,
121
+ canonicalTaskName,
122
+ taskPath,
123
+ threadId,
124
+ l1State,
125
+ ...(l2State ? { l2State } : {}),
126
+ lastActivityAt,
127
+ ownedProcesses,
128
+ outcome,
129
+ ...(executorBlocking ? { blocking: executorBlocking } : {}),
130
+ continuationGeneration,
131
+ continuationCount,
132
+ },
133
+ ...(blocking ? { blocking } : {}),
134
+ };
135
+ }
136
+ export function classifyExecutorOutcome(input) {
137
+ if (input.objectiveComplete)
138
+ return { outcome: 'objective_complete' };
139
+ if (input.reportedOutcome === 'cancelled' || input.reportedOutcome === 'failed')
140
+ return { outcome: input.reportedOutcome };
141
+ const blocking = input.blockingReason && input.resumeCondition
142
+ ? { reason: input.blockingReason, resumeCondition: input.resumeCondition }
143
+ : undefined;
144
+ if (input.reportedOutcome === 'blocked' && validStructuredBlock(blocking))
145
+ return { outcome: 'blocked', blocking };
146
+ // A turn ending, a checkpoint, or free-form language about time/context is
147
+ // not objective state. Incomplete Org state remains mechanically partial.
148
+ return { outcome: 'partial' };
149
+ }
150
+ export function decideSupervisedLifecycle(input) {
151
+ if (input.explicitlyPausedOrCancelled || executionComplete(input.plan))
152
+ return { finalAllowed: true, action: { kind: 'allowFinal' } };
153
+ if (validStructuredBlock(input.attention))
154
+ return {
155
+ finalAllowed: true,
156
+ action: { kind: 'invokeAttention', ...input.attention },
157
+ };
158
+ const processes = input.executor?.ownedProcesses ?? [];
159
+ const overBudget = processes.find((process) => (process.state === 'running' || process.state === 'detached-active') &&
160
+ (process.elapsedMs > input.policy.processMaxElapsedMs ||
161
+ (process.rssBytes !== null && process.rssBytes > input.policy.processMaxRssBytes)));
162
+ if (overBudget)
163
+ return {
164
+ finalAllowed: false,
165
+ action: {
166
+ kind: 'terminateProcess',
167
+ threadId: overBudget.ownerThreadId,
168
+ processId: overBudget.processId,
169
+ },
170
+ };
171
+ const exited = processes.find((process) => process.state === 'exited-awaiting-result' && process.resultArtifact);
172
+ if (exited)
173
+ return {
174
+ finalAllowed: false,
175
+ action: {
176
+ kind: 'consumeProcessResult',
177
+ threadId: exited.ownerThreadId,
178
+ processId: exited.processId,
179
+ resultArtifact: exited.resultArtifact,
180
+ },
181
+ };
182
+ const active = processes.find((process) => process.state === 'running' || process.state === 'detached-active');
183
+ if (active)
184
+ return {
185
+ finalAllowed: false,
186
+ action: {
187
+ kind: 'monitorProcess',
188
+ process: { ...active, ownership: 'supervisor', state: 'detached-active' },
189
+ pollAfterMs: input.policy.processPollMs,
190
+ },
191
+ };
192
+ if (input.event === 'waitTimeout' && !input.executor)
193
+ return { finalAllowed: false, action: { kind: 'reinspect' } };
194
+ if (!input.executor || input.executor.l1State === 'DONE')
195
+ return { finalAllowed: false, action: { kind: 'continueSupervisor' } };
196
+ const exponent = Math.min(30, Math.max(0, input.executor.continuationCount));
197
+ const delayMs = Math.min(input.policy.continuationMaxDelayMs, input.policy.continuationBaseDelayMs * 2 ** exponent);
198
+ return {
199
+ finalAllowed: false,
200
+ action: {
201
+ kind: 'resumeExecutor',
202
+ threadId: input.executor.threadId,
203
+ generation: input.executor.continuationGeneration + 1,
204
+ delayMs,
205
+ },
206
+ };
207
+ }
208
+ export function executorIdentity(canonicalTaskName, generation) {
209
+ if (!/^l[1-9]\d*(?:_[1-9]\d*)?$/.test(canonicalTaskName) ||
210
+ !Number.isSafeInteger(generation) ||
211
+ generation < 1)
212
+ throw new Error('INVALID_EXECUTOR_IDENTITY');
213
+ return {
214
+ canonicalTaskName,
215
+ canonicalPosition: orgPlanAgentDisplayName(canonicalTaskName),
216
+ generation,
217
+ taskName: generation === 1 ? canonicalTaskName : `${canonicalTaskName}_g${generation}`,
218
+ };
219
+ }
220
+ function executionComplete(plan) {
221
+ return (plan.executionComplete ??
222
+ plan.steps.every((step) => step.state === 'DONE' &&
223
+ step.reviewStatus === 'REVIEWED' &&
224
+ step.children.every((child) => child.state === 'DONE')));
225
+ }
226
+ function record(value) {
227
+ return value && typeof value === 'object' && !Array.isArray(value)
228
+ ? value
229
+ : undefined;
230
+ }
231
+ function parseStructuredBlock(value) {
232
+ const reason = stringValue(value.reason, Object.keys(blockingResumeConditions));
233
+ const resumeCondition = stringValue(value.resumeCondition, Object.values(blockingResumeConditions));
234
+ const blocking = reason && resumeCondition ? { reason, resumeCondition } : undefined;
235
+ return validStructuredBlock(blocking) ? blocking : undefined;
236
+ }
237
+ function boundedText(value) {
238
+ return typeof value === 'string' && value.length > 0 && value.length <= 512 ? value : undefined;
239
+ }
240
+ function stringValue(value, values) {
241
+ return typeof value === 'string' && values.includes(value) ? value : undefined;
242
+ }
243
+ function nonNegativeNumber(value) {
244
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
245
+ }
246
+ function nullableNonNegativeNumber(value) {
247
+ return value === null ? null : (nonNegativeNumber(value) ?? undefined);
248
+ }
249
+ function integer(value) {
250
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
251
+ }
252
+ function nonNegativeInteger(value) {
253
+ const parsed = integer(value);
254
+ return parsed !== null && parsed >= 0 ? parsed : null;
255
+ }
@@ -29,6 +29,7 @@ class SessionResource {
29
29
  writtenThreadName;
30
30
  capabilities = new Map();
31
31
  spawnedAgentModels = new Map();
32
+ ownedChildProcesses = new Map();
32
33
  constructor(sessionId, process, planStatusLease, planMeasurementToken, unregister, onDisposed) {
33
34
  this.sessionId = sessionId;
34
35
  this.process = process;
@@ -182,6 +183,17 @@ export class CodexSessionRuntime {
182
183
  }));
183
184
  return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
184
185
  }
186
+ async startExecutorTurn(session, childThreadId, text, clientUserMessageId) {
187
+ const resource = this.sessions.get(session.id);
188
+ if (!resource)
189
+ throw new Error('CODEX_SESSION_NOT_RUNNING');
190
+ return decodeTurnStart(await resource.process.rpc.request('turn/start', {
191
+ threadId: childThreadId,
192
+ input: [{ type: 'text', text, text_elements: [] }],
193
+ clientUserMessageId,
194
+ ...(session.model ? { model: session.model } : {}),
195
+ }));
196
+ }
185
197
  /** The sole authoritative in-process ownership probe. It never launches a child. */
186
198
  ownsWriter(sessionId) {
187
199
  return this.sessions.get(sessionId)?.active === true;
@@ -315,6 +327,9 @@ export class CodexSessionRuntime {
315
327
  ...(owned.spawnedAgentModels.get(value.id)
316
328
  ? { model: owned.spawnedAgentModels.get(value.id) }
317
329
  : {}),
330
+ ...(decodeAgentTaskPath(value.source)
331
+ ? { taskPath: decodeAgentTaskPath(value.source) }
332
+ : {}),
318
333
  },
319
334
  ];
320
335
  }));
@@ -332,6 +347,134 @@ export class CodexSessionRuntime {
332
347
  throw new Error('CODEX_CHILD_LIST_UNSUPPORTED');
333
348
  return children;
334
349
  }
350
+ /** Reads only bounded process metadata; command text and output never enter lifecycle state. */
351
+ async inspectChildProcesses(session, child) {
352
+ const owned = this.sessions.get(session.id);
353
+ if (!owned)
354
+ return [];
355
+ const active = await this.listChildBackgroundTerminals(owned, child);
356
+ const activeIds = new Set(active.map((process) => process.processId));
357
+ for (const process of active)
358
+ owned.ownedChildProcesses.set(childProcessKey(child.id, process.processId), process);
359
+ const prior = [...owned.ownedChildProcesses.values()].filter((process) => process.ownerThreadId === child.id && !activeIds.has(process.processId));
360
+ if (prior.some((process) => process.state === 'running' || process.state === 'detached-active')) {
361
+ const results = await this.readChildProcessResults(owned, child.id);
362
+ for (const process of prior) {
363
+ if (process.state !== 'running' && process.state !== 'detached-active')
364
+ continue;
365
+ const result = results.get(process.itemId);
366
+ owned.ownedChildProcesses.set(childProcessKey(child.id, process.processId), {
367
+ ...process,
368
+ state: 'exited-awaiting-result',
369
+ cpuPercent: 0,
370
+ rssBytes: 0,
371
+ ...(result?.exitStatus === undefined ? {} : { exitStatus: result.exitStatus }),
372
+ resultArtifact: `${child.id}:${process.itemId}`,
373
+ });
374
+ }
375
+ }
376
+ return [...owned.ownedChildProcesses.values()].filter((process) => process.ownerThreadId === child.id &&
377
+ process.state !== 'result-consumed' &&
378
+ process.state !== 'terminated-for-budget');
379
+ }
380
+ consumeChildProcessResult(sessionId, childThreadId, processId) {
381
+ this.sessions
382
+ .get(sessionId)
383
+ ?.ownedChildProcesses.delete(childProcessKey(childThreadId, processId));
384
+ }
385
+ async terminateChildProcess(session, childThreadId, processId) {
386
+ const owned = this.sessions.get(session.id);
387
+ if (!owned)
388
+ return false;
389
+ const result = await owned.process.rpc.request('thread/backgroundTerminals/terminate', {
390
+ threadId: childThreadId,
391
+ processId,
392
+ });
393
+ const terminated = asRecord(result)?.terminated === true;
394
+ if (terminated) {
395
+ const key = childProcessKey(childThreadId, processId);
396
+ const process = owned.ownedChildProcesses.get(key);
397
+ if (process)
398
+ owned.ownedChildProcesses.set(key, { ...process, state: 'terminated-for-budget' });
399
+ }
400
+ return terminated;
401
+ }
402
+ async listChildBackgroundTerminals(owned, child) {
403
+ const processes = [];
404
+ const cursors = new Set();
405
+ let cursor;
406
+ for (let page = 0; page < 4 && processes.length < 64; page += 1) {
407
+ const result = await owned.process.rpc.request('thread/backgroundTerminals/list', {
408
+ threadId: child.id,
409
+ limit: 64,
410
+ ...(cursor ? { cursor } : {}),
411
+ });
412
+ const response = asRecord(result);
413
+ const data = Array.isArray(response?.data) ? response.data : [];
414
+ for (const candidate of data) {
415
+ const value = asRecord(candidate);
416
+ const itemId = boundedString(value?.itemId, 256);
417
+ const processId = boundedString(value?.processId, 256);
418
+ if (!itemId || !processId || processes.length >= 64)
419
+ continue;
420
+ const key = childProcessKey(child.id, processId);
421
+ const before = owned.ownedChildProcesses.get(key);
422
+ const observedAt = before?.observedAt ?? new Date().toISOString();
423
+ const rssKb = boundedNonNegativeNumber(value?.rssKb);
424
+ processes.push({
425
+ processId,
426
+ itemId,
427
+ ownerThreadId: child.id,
428
+ ownerTaskPath: child.taskPath ?? child.id,
429
+ ownership: before?.ownership ?? 'executor',
430
+ state: before?.state === 'detached-active' ? 'detached-active' : 'running',
431
+ observedAt,
432
+ elapsedMs: Math.max(0, Date.now() - Date.parse(observedAt)),
433
+ cpuPercent: boundedNonNegativeNumber(value?.cpuPercent),
434
+ rssBytes: rssKb === null ? null : Math.min(Number.MAX_SAFE_INTEGER, rssKb * 1024),
435
+ ...(boundedNonNegativeInteger(value?.osPid) === null
436
+ ? {}
437
+ : { osPid: boundedNonNegativeInteger(value?.osPid) }),
438
+ });
439
+ }
440
+ const next = boundedString(response?.nextCursor, 256);
441
+ if (!next)
442
+ return processes;
443
+ if (cursors.has(next) || processes.length >= 64)
444
+ throw new Error('CODEX_BACKGROUND_TERMINAL_LIST_UNSUPPORTED');
445
+ cursors.add(next);
446
+ cursor = next;
447
+ }
448
+ if (cursor)
449
+ throw new Error('CODEX_BACKGROUND_TERMINAL_LIST_UNSUPPORTED');
450
+ return processes;
451
+ }
452
+ async readChildProcessResults(owned, childThreadId) {
453
+ const result = await owned.process.rpc.request('thread/read', {
454
+ threadId: childThreadId,
455
+ includeTurns: true,
456
+ });
457
+ const decoded = new Map();
458
+ const turns = asRecord(asRecord(result)?.thread)?.turns;
459
+ if (!Array.isArray(turns) || turns.length > 10_000)
460
+ return decoded;
461
+ for (const turn of turns) {
462
+ const items = asRecord(turn)?.items;
463
+ if (!Array.isArray(items) || items.length > 10_000)
464
+ continue;
465
+ for (const candidate of items) {
466
+ const item = asRecord(candidate);
467
+ const itemId = boundedString(item?.id, 256);
468
+ if (item?.type !== 'commandExecution' ||
469
+ !itemId ||
470
+ !['completed', 'failed', 'declined'].includes(String(item.status)))
471
+ continue;
472
+ const exitStatus = boundedInteger(item.exitCode);
473
+ decoded.set(itemId, exitStatus === null ? {} : { exitStatus });
474
+ }
475
+ }
476
+ return decoded;
477
+ }
335
478
  async readDetachedHistory(session) {
336
479
  // A reader is intentionally not a SessionResource: it owns no subscriptions,
337
480
  // runtime registration, plan lease, or writer state and is closed on every path.
@@ -622,6 +765,26 @@ function threadTokenUsage(value) {
622
765
  function asRecord(value) {
623
766
  return value && typeof value === 'object' ? value : undefined;
624
767
  }
768
+ function decodeAgentTaskPath(value) {
769
+ const subagent = asRecord(asRecord(value)?.subagent);
770
+ const spawn = asRecord(subagent?.thread_spawn);
771
+ const path = boundedString(spawn?.agent_path, 256);
772
+ return path?.startsWith('/') && !path.includes('..') ? path : undefined;
773
+ }
774
+ function childProcessKey(childThreadId, processId) {
775
+ return `${childThreadId}:${processId}`;
776
+ }
777
+ function boundedNonNegativeNumber(value) {
778
+ const number = typeof value === 'bigint' ? Number(value) : value;
779
+ return typeof number === 'number' && Number.isFinite(number) && number >= 0 ? number : null;
780
+ }
781
+ function boundedInteger(value) {
782
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
783
+ }
784
+ function boundedNonNegativeInteger(value) {
785
+ const number = boundedInteger(value);
786
+ return number !== null && number >= 0 ? number : null;
787
+ }
625
788
  function decodeThreadStart(value) {
626
789
  const id = asRecord(asRecord(value)?.thread)?.id;
627
790
  if (typeof id !== 'string' || !id || id.length > 256)
@@ -3,10 +3,10 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, sandbox TEXT, approval_policy TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
6
+ const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, sandbox TEXT, approval_policy TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, last_org_plan_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, turn_id TEXT, requested_at TEXT, resolved_at TEXT, outcome TEXT, operation_key TEXT, resolution_state TEXT NOT NULL DEFAULT 'active', PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, autopilot_outbox_id INTEGER, PRIMARY KEY (session_id, sequence), UNIQUE(session_id, autopilot_outbox_id)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key)); CREATE TABLE IF NOT EXISTS autopilot_sessions (session_id TEXT PRIMARY KEY REFERENCES relay_sessions(id) ON DELETE CASCADE, state TEXT NOT NULL, requested_enabled INTEGER NOT NULL, plan_identity TEXT, plan_fingerprint TEXT, generation INTEGER NOT NULL, no_progress_count INTEGER NOT NULL, next_evaluation_at TEXT, last_control_id TEXT, stop_reason TEXT, lifecycle_json TEXT, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS autopilot_controls (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, control_id TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, failure_code TEXT, turn_id TEXT, PRIMARY KEY (session_id, control_id)); CREATE TABLE IF NOT EXISTS autopilot_outbox (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, type TEXT NOT NULL, payload_json TEXT NOT NULL, occurred_at TEXT NOT NULL);`;
7
7
  export function migrate(database) {
8
8
  database.exec(schema);
9
- database.exec("CREATE INDEX IF NOT EXISTS session_events_autopilot_audit_tail_v2 ON session_events(session_id, type, sequence DESC) WHERE type IN ('autopilot.continuation-scheduled','autopilot.control-issued','autopilot.turn-started','autopilot.turn-failed','autopilot.progress-reset','autopilot.updated','org-plan.attention-required','org-plan.attention-resolved')");
9
+ database.exec("CREATE INDEX IF NOT EXISTS session_events_autopilot_audit_tail_v3 ON session_events(session_id, type, sequence DESC) WHERE type IN ('autopilot.continuation-scheduled','autopilot.control-issued','autopilot.turn-started','autopilot.turn-failed','autopilot.progress-reset','autopilot.final-rejected','autopilot.executor-resumed','autopilot.process-monitoring','autopilot.process-result-consumed','autopilot.process-terminated','autopilot.updated','org-plan.attention-required','org-plan.attention-resolved')");
10
10
  const columns = database.prepare('PRAGMA table_info(relay_sessions)').all();
11
11
  if (!columns.some((column) => column.name === 'effective_skill_selection_json'))
12
12
  database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
@@ -22,6 +22,11 @@ export function migrate(database) {
22
22
  database.exec('ALTER TABLE relay_sessions ADD COLUMN sandbox TEXT');
23
23
  if (!columns.some((column) => column.name === 'approval_policy'))
24
24
  database.exec('ALTER TABLE relay_sessions ADD COLUMN approval_policy TEXT');
25
+ const autopilotColumns = database
26
+ .prepare('PRAGMA table_info(autopilot_sessions)')
27
+ .all();
28
+ if (!autopilotColumns.some((column) => column.name === 'lifecycle_json'))
29
+ database.exec('ALTER TABLE autopilot_sessions ADD COLUMN lifecycle_json TEXT');
25
30
  const interactionColumns = database
26
31
  .prepare('PRAGMA table_info(pending_interactions)')
27
32
  .all();
@@ -3,6 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { parsePersistedSupervisedLifecycle } from '../../features/autopilot/domain/supervised-lifecycle.js';
6
7
  export class SqliteAutopilotStore {
7
8
  db;
8
9
  constructor(db) {
@@ -14,6 +15,7 @@ export class SqliteAutopilotStore {
14
15
  .get(sessionId);
15
16
  if (!row)
16
17
  return null;
18
+ const lifecycle = parseLifecycle(row.lifecycle_json);
17
19
  if (!['disabled', 'monitoring', 'backoff', 'attentionRequired', 'completed'].includes(String(row.state)) ||
18
20
  ![
19
21
  'manualDisabled',
@@ -34,7 +36,8 @@ export class SqliteAutopilotStore {
34
36
  Number(row.generation) < 0 ||
35
37
  !Number.isSafeInteger(Number(row.no_progress_count)) ||
36
38
  Number(row.no_progress_count) < 0 ||
37
- ![0, 1].includes(Number(row.requested_enabled)))
39
+ ![0, 1].includes(Number(row.requested_enabled)) ||
40
+ lifecycle === null)
38
41
  return null;
39
42
  return {
40
43
  sessionId: String(row.session_id),
@@ -47,13 +50,16 @@ export class SqliteAutopilotStore {
47
50
  nextEvaluationAt: row.next_evaluation_at === null ? null : String(row.next_evaluation_at),
48
51
  lastControlId: row.last_control_id === null ? null : String(row.last_control_id),
49
52
  stopReason: row.stop_reason,
53
+ ...lifecycle,
50
54
  updatedAt: String(row.updated_at),
51
55
  };
52
56
  }
53
57
  save(state) {
54
58
  this.db
55
- .prepare('INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET state=excluded.state,requested_enabled=excluded.requested_enabled,plan_identity=excluded.plan_identity,plan_fingerprint=excluded.plan_fingerprint,generation=excluded.generation,no_progress_count=excluded.no_progress_count,next_evaluation_at=excluded.next_evaluation_at,last_control_id=excluded.last_control_id,stop_reason=excluded.stop_reason,updated_at=excluded.updated_at')
56
- .run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.updatedAt);
59
+ .prepare('INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,lifecycle_json,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET state=excluded.state,requested_enabled=excluded.requested_enabled,plan_identity=excluded.plan_identity,plan_fingerprint=excluded.plan_fingerprint,generation=excluded.generation,no_progress_count=excluded.no_progress_count,next_evaluation_at=excluded.next_evaluation_at,last_control_id=excluded.last_control_id,stop_reason=excluded.stop_reason,lifecycle_json=excluded.lifecycle_json,updated_at=excluded.updated_at')
60
+ .run(state.sessionId, state.state, state.requestedEnabled ? 1 : 0, state.planIdentity, state.planFingerprint, state.generation, state.consecutiveNoProgress, state.nextEvaluationAt, state.lastControlId, state.stopReason, state.executor || state.blocking
61
+ ? JSON.stringify({ executor: state.executor, blocking: state.blocking })
62
+ : null, state.updatedAt);
57
63
  }
58
64
  findControl(sessionId, controlId) {
59
65
  const row = this.db
@@ -158,3 +164,15 @@ export class SqliteAutopilotStore {
158
164
  this.db.prepare('DELETE FROM autopilot_sessions WHERE session_id = ?').run(sessionId);
159
165
  }
160
166
  }
167
+ function parseLifecycle(value) {
168
+ if (value === null || value === undefined)
169
+ return {};
170
+ if (typeof value !== 'string' || value.length > 256_000)
171
+ return null;
172
+ try {
173
+ return parsePersistedSupervisedLifecycle(JSON.parse(value)) ?? null;
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
@@ -6,6 +6,11 @@
6
6
  export const autopilotAuditEventTypes = [
7
7
  'autopilot.turn-started',
8
8
  'autopilot.turn-failed',
9
+ 'autopilot.final-rejected',
10
+ 'autopilot.executor-resumed',
11
+ 'autopilot.process-monitoring',
12
+ 'autopilot.process-result-consumed',
13
+ 'autopilot.process-terminated',
9
14
  'autopilot.updated',
10
15
  'org-plan.attention-required',
11
16
  'org-plan.attention-resolved',
@@ -7,6 +7,18 @@
7
7
  export function autopilotAuditLabel(type, payload) {
8
8
  if (type === 'autopilot.turn-started')
9
9
  return 'Continued execution automatically';
10
+ if (type === 'autopilot.final-rejected')
11
+ return 'Kept incomplete supervised work active';
12
+ if (type === 'autopilot.executor-resumed')
13
+ return 'Resumed the assigned executor';
14
+ if (type === 'autopilot.process-monitoring')
15
+ return 'Monitoring executor background work';
16
+ if (type === 'autopilot.process-result-consumed')
17
+ return 'Consumed background work result';
18
+ if (type === 'autopilot.process-terminated')
19
+ return property(payload, 'reason') === 'resourceBudget'
20
+ ? 'Stopped background work at its resource limit'
21
+ : 'Stopped executor background work';
10
22
  if (type === 'autopilot.turn-failed') {
11
23
  const code = property(payload, 'code');
12
24
  return code === 'START_UNAVAILABLE'
@@ -6,8 +6,18 @@
6
6
  export function orgPlanPosition(l1Position, l2Position) {
7
7
  return l2Position === undefined ? `L${l1Position}` : `L${l1Position}.${l2Position}`;
8
8
  }
9
+ export function parseOrgPlanAgentIdentity(nameOrPath) {
10
+ const name = nameOrPath.split('/').filter(Boolean).at(-1) ?? nameOrPath;
11
+ const match = /^(l([1-9]\d*)(?:_([1-9]\d*))?)(?:_g([1-9]\d*))?$/.exec(name);
12
+ if (!match)
13
+ return null;
14
+ return {
15
+ canonicalTaskName: match[1],
16
+ canonicalPosition: orgPlanPosition(Number(match[2]), match[3] ? Number(match[3]) : undefined),
17
+ generation: match[4] ? Number(match[4]) : 1,
18
+ };
19
+ }
9
20
  /** Converts the collaboration API's tool-safe task name into its canonical plan label. */
10
21
  export function orgPlanAgentDisplayName(name) {
11
- const match = /^l([1-9]\d*)(?:_([1-9]\d*))?$/.exec(name);
12
- return match ? orgPlanPosition(Number(match[1]), match[2] ? Number(match[2]) : undefined) : name;
22
+ return parseOrgPlanAgentIdentity(name)?.canonicalPosition ?? name;
13
23
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",