gestalt-mobile 0.5.0 → 0.6.0

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,242 @@
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 { isAbsolute, relative, resolve } from 'node:path';
7
+ const headingPattern = /^(\*{1,2}) (TODO|WIP|DONE) \[#([ABC])\] (.+)$/;
8
+ const propertyPattern = /^:([A-Z_]+):(?:[ \t](.*))?$/;
9
+ const descriptionPattern = /^- ([A-Za-z][A-Za-z ]*?) ::(?:[ \t](.*))?$/;
10
+ /**
11
+ * Projects only the small, validated org-plan dialect used by supervised plans.
12
+ * `planPath` is used solely for admission control and never retained in the model.
13
+ */
14
+ export function parseSupervisedPlan(input) {
15
+ if (!isPlanPathWithinWorkspace(input.planPath, input.workspacePath))
16
+ return unavailable('PATH_OUTSIDE_WORKSPACE');
17
+ const lines = input.source.replace(/\r\n?/g, '\n').split('\n');
18
+ const metadata = new Map();
19
+ const headings = [];
20
+ for (let index = 0; index < lines.length;) {
21
+ const line = lines[index];
22
+ const metadataMatch = /^#\+([A-Z]+):(?:[ \t](.*))?$/.exec(line);
23
+ if (metadataMatch) {
24
+ metadata.set(metadataMatch[1], metadataMatch[2] ?? '');
25
+ index += 1;
26
+ continue;
27
+ }
28
+ if (!line.startsWith('*')) {
29
+ index += 1;
30
+ continue;
31
+ }
32
+ const headingMatch = headingPattern.exec(line);
33
+ if (!headingMatch)
34
+ return unavailable('MALFORMED_ORG');
35
+ const level = headingMatch[1].length;
36
+ const propertiesStart = lines[index + 1];
37
+ if (propertiesStart !== ':PROPERTIES:')
38
+ return unavailable('MALFORMED_ORG');
39
+ const properties = new Map();
40
+ index += 2;
41
+ let closed = false;
42
+ for (; index < lines.length; index += 1) {
43
+ const propertyLine = lines[index];
44
+ if (propertyLine === ':END:') {
45
+ closed = true;
46
+ index += 1;
47
+ break;
48
+ }
49
+ const property = propertyPattern.exec(propertyLine);
50
+ if (!property || properties.has(property[1]))
51
+ return unavailable('MALFORMED_ORG');
52
+ properties.set(property[1], property[2] ?? '');
53
+ }
54
+ if (!closed)
55
+ return unavailable('MALFORMED_ORG');
56
+ const descriptions = new Map();
57
+ while (index < lines.length && !lines[index].startsWith('*')) {
58
+ const description = descriptionPattern.exec(lines[index]);
59
+ if (description) {
60
+ const key = description[1];
61
+ if (descriptions.has(key))
62
+ return unavailable('MALFORMED_ORG');
63
+ descriptions.set(key, description[2] ?? '');
64
+ }
65
+ else if (lines[index].trim() !== '') {
66
+ return unavailable('MALFORMED_ORG');
67
+ }
68
+ index += 1;
69
+ }
70
+ const id = properties.get('ID');
71
+ if (!id)
72
+ return unavailable('MISSING_REQUIRED_FIELD');
73
+ headings.push({
74
+ id,
75
+ title: headingMatch[4],
76
+ level,
77
+ state: headingMatch[2],
78
+ priority: headingMatch[3],
79
+ properties,
80
+ descriptions,
81
+ });
82
+ }
83
+ const title = metadata.get('TITLE');
84
+ if (!title)
85
+ return unavailable('MISSING_TITLE');
86
+ if (!headings.length)
87
+ return unavailable('MALFORMED_ORG');
88
+ if (new Set(headings.map((heading) => heading.id)).size !== headings.length)
89
+ return unavailable('DUPLICATE_ID');
90
+ if (!hasValidWipPath(headings))
91
+ return unavailable('MULTIPLE_WIP');
92
+ const built = buildSteps(headings);
93
+ if (!built)
94
+ return unavailable('MISSING_REQUIRED_FIELD');
95
+ const allSteps = headings;
96
+ const allDone = allSteps.every((step) => step.state === 'DONE');
97
+ const currentStepId = findCurrentStepId(allSteps, allDone);
98
+ if (!currentStepId)
99
+ return unavailable('MALFORMED_ORG');
100
+ return {
101
+ kind: 'available',
102
+ plan: freezePlan({
103
+ title,
104
+ ...(metadata.has('SUBTITLE') ? { subtitle: metadata.get('SUBTITLE') } : {}),
105
+ ...(metadata.has('DATE') ? { date: metadata.get('DATE') } : {}),
106
+ ...(metadata.has('KEYWORDS') ? { keywords: metadata.get('KEYWORDS') } : {}),
107
+ steps: built,
108
+ totalSteps: allSteps.length,
109
+ doneSteps: allSteps.filter((step) => step.state === 'DONE').length,
110
+ allDone,
111
+ currentStepId,
112
+ }),
113
+ };
114
+ }
115
+ export function isPlanPathWithinWorkspace(planPath, workspacePath) {
116
+ if (!isAbsolute(planPath) || !isAbsolute(workspacePath))
117
+ return false;
118
+ const pathWithinWorkspace = relative(resolve(workspacePath), resolve(planPath));
119
+ return (pathWithinWorkspace === '' ||
120
+ (!pathWithinWorkspace.startsWith('..') && !isAbsolute(pathWithinWorkspace)));
121
+ }
122
+ function buildSteps(headings) {
123
+ const parents = [];
124
+ for (let index = 0; index < headings.length; index += 1) {
125
+ const heading = headings[index];
126
+ if (heading.level === 1) {
127
+ const step = makeStep(heading);
128
+ if (!step)
129
+ return null;
130
+ parents.push(step);
131
+ continue;
132
+ }
133
+ const parent = parents.at(-1);
134
+ if (!parent)
135
+ return null;
136
+ const child = makeStep(heading);
137
+ if (!child)
138
+ return null;
139
+ parent.children.push(child);
140
+ }
141
+ return parents;
142
+ }
143
+ function makeStep(heading) {
144
+ const description = descriptionFor(heading);
145
+ if (!description)
146
+ return null;
147
+ if (heading.level === 1) {
148
+ const reviewStatus = heading.properties.get('REVIEW_STATUS');
149
+ const skills = heading.properties.get('SKILLS');
150
+ if ((reviewStatus !== 'UNREVIEWED' && reviewStatus !== 'REVIEWED') || skills === undefined)
151
+ return null;
152
+ return {
153
+ id: heading.id,
154
+ title: heading.title,
155
+ level: heading.level,
156
+ state: heading.state,
157
+ priority: heading.priority,
158
+ reviewStatus: reviewStatus,
159
+ skills: skills.split(/\s+/).filter(Boolean),
160
+ description,
161
+ children: [],
162
+ };
163
+ }
164
+ if (heading.properties.has('REVIEW_STATUS') || heading.properties.has('SKILLS'))
165
+ return null;
166
+ return {
167
+ id: heading.id,
168
+ title: heading.title,
169
+ level: heading.level,
170
+ state: heading.state,
171
+ priority: heading.priority,
172
+ description,
173
+ children: [],
174
+ };
175
+ }
176
+ function descriptionFor(heading) {
177
+ const fields = heading.level === 1 ? ['Effort', 'Goal', 'Notes'] : ['Why', 'Change', 'Tests', 'Done when'];
178
+ if (!fields.every((field) => heading.descriptions.has(field)))
179
+ return null;
180
+ const description = heading.descriptions;
181
+ return heading.level === 1
182
+ ? {
183
+ effort: description.get('Effort'),
184
+ goal: description.get('Goal'),
185
+ notes: description.get('Notes'),
186
+ }
187
+ : {
188
+ why: description.get('Why'),
189
+ change: description.get('Change'),
190
+ tests: description.get('Tests'),
191
+ doneWhen: description.get('Done when'),
192
+ };
193
+ }
194
+ function hasValidWipPath(headings) {
195
+ const wipByLevel = new Map();
196
+ for (const heading of headings) {
197
+ if (heading.state !== 'WIP')
198
+ continue;
199
+ const sameLevel = wipByLevel.get(heading.level) ?? [];
200
+ sameLevel.push(heading);
201
+ wipByLevel.set(heading.level, sameLevel);
202
+ }
203
+ if ([...wipByLevel.values()].some((steps) => steps.length > 1))
204
+ return false;
205
+ const l2Wip = wipByLevel.get(2)?.[0];
206
+ if (!l2Wip)
207
+ return true;
208
+ const parentIndex = headings.indexOf(l2Wip) - 1;
209
+ for (let index = parentIndex; index >= 0; index -= 1) {
210
+ if (headings[index].level === 1)
211
+ return headings[index].state === 'WIP';
212
+ }
213
+ return false;
214
+ }
215
+ function findCurrentStepId(headings, allDone) {
216
+ const wip = headings
217
+ .filter((heading) => heading.state === 'WIP')
218
+ .sort((a, b) => b.level - a.level)[0];
219
+ if (wip)
220
+ return wip.id;
221
+ const review = headings.find((heading) => heading.level === 1 &&
222
+ heading.state === 'DONE' &&
223
+ heading.properties.get('REVIEW_STATUS') === 'UNREVIEWED');
224
+ if (review)
225
+ return review.id;
226
+ const todo = headings.find((heading) => heading.state === 'TODO');
227
+ if (todo)
228
+ return todo.id;
229
+ return allDone ? (headings.findLast((heading) => heading.level === 1)?.id ?? null) : null;
230
+ }
231
+ function freezePlan(plan) {
232
+ const freezeStep = (step) => Object.freeze({
233
+ ...step,
234
+ ...(step.skills ? { skills: Object.freeze([...step.skills]) } : {}),
235
+ description: Object.freeze({ ...step.description }),
236
+ children: Object.freeze(step.children.map(freezeStep)),
237
+ });
238
+ return Object.freeze({ ...plan, steps: Object.freeze(plan.steps.map(freezeStep)) });
239
+ }
240
+ function unavailable(reason) {
241
+ return { kind: 'unavailable', reason };
242
+ }
@@ -0,0 +1,6 @@
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
+ export {};
@@ -0,0 +1,22 @@
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
+ /** Retains at most one validated plan projection for each relay session. */
7
+ export class SupervisedPlanRegistry {
8
+ plans = new Map();
9
+ accept(sessionId, update) {
10
+ if (update.kind === 'updated')
11
+ this.plans.set(sessionId, { plan: update.plan, identity: update.identity });
12
+ }
13
+ find(sessionId) {
14
+ return this.plans.get(sessionId)?.plan ?? null;
15
+ }
16
+ identity(sessionId) {
17
+ return this.plans.get(sessionId)?.identity ?? null;
18
+ }
19
+ clear(sessionId) {
20
+ this.plans.delete(sessionId);
21
+ }
22
+ }
@@ -0,0 +1,33 @@
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 { problem } from '../../../platform/http/problem.js';
7
+ export function registerClosePlan(app, deps) {
8
+ app.delete('/api/sessions/:id/plan', async (request, reply) => {
9
+ const id = request.params.id;
10
+ if (!deps.exists(id))
11
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
+ const plan = deps.find(id);
13
+ if (!plan)
14
+ return reply.code(204).send();
15
+ if (!plan.allDone)
16
+ return reply
17
+ .code(409)
18
+ .type('application/problem+json')
19
+ .send(problem('PLAN_INCOMPLETE', 409, 'Only completed supervised plans can be closed.'));
20
+ try {
21
+ await deps.removeStatus(id);
22
+ }
23
+ catch {
24
+ return reply
25
+ .code(503)
26
+ .type('application/problem+json')
27
+ .send(problem('PLAN_CLOSE_UNAVAILABLE', 503, 'The relay could not close the supervised plan.', true));
28
+ }
29
+ deps.clear(id);
30
+ deps.closed(id);
31
+ return reply.code(204).send();
32
+ });
33
+ }
@@ -0,0 +1,6 @@
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
+ export {};
@@ -0,0 +1,14 @@
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
+ export function registerGetPlan(app, deps) {
7
+ app.get('/api/sessions/:id/plan', (request, reply) => {
8
+ const id = request.params.id;
9
+ if (!deps.exists(id))
10
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
11
+ const plan = deps.find(id);
12
+ return plan ? reply.send(plan) : reply.code(204).send();
13
+ });
14
+ }
@@ -67,6 +67,11 @@ export class RelaySession {
67
67
  bindThread(value, now) {
68
68
  return this.transition({ threadId: threadId(value), state: 'ready' }, 'ThreadBound', now);
69
69
  }
70
+ selectModel(value, now) {
71
+ if (this.value.state !== 'ready')
72
+ throw new DomainError('SESSION_NOT_READY');
73
+ return this.transition({ model: value }, 'ModelSelected', now);
74
+ }
70
75
  startTurn(value, now) {
71
76
  if (this.value.state === 'turnActive')
72
77
  throw new DomainError('SESSION_TURN_ACTIVE');
@@ -0,0 +1,23 @@
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 { RelaySession } from '../model/relay-session.js';
7
+ export function registerSelectModel(app, deps) {
8
+ app.post('/api/sessions/:id/model', async (request, reply) => {
9
+ const session = deps.find(request.params.id);
10
+ if (!session)
11
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
12
+ const model = request.body.model;
13
+ if (typeof model !== 'string')
14
+ return reply.code(400).send({ code: 'MODEL_REQUIRED' });
15
+ if (!(await deps.models()).includes(model))
16
+ return reply.code(400).send({ code: 'MODEL_UNAVAILABLE' });
17
+ if (session.state !== 'ready')
18
+ return reply.code(409).send({ code: 'SESSION_NOT_READY' });
19
+ const selected = RelaySession.rehydrate(session).selectModel(model, deps.now()).snapshot;
20
+ deps.save(selected);
21
+ return reply.send(selected);
22
+ });
23
+ }
@@ -10,6 +10,7 @@ export function launchCodexAppServer(input) {
10
10
  const launch = profileAppServerCommand(input.profile, undefined, input.skillsConfig);
11
11
  const child = spawn(launch.command, launch.args, {
12
12
  cwd: input.cwd,
13
+ env: codexChildEnvironment(input.environment),
13
14
  shell: false,
14
15
  stdio: 'pipe',
15
16
  });
@@ -25,3 +26,10 @@ export function launchCodexAppServer(input) {
25
26
  },
26
27
  };
27
28
  }
29
+ /** Prevents a relay's own ambient status path from reaching discovery-only children. */
30
+ export function codexChildEnvironment(environment) {
31
+ const inherited = { ...process.env };
32
+ delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_FILE;
33
+ delete inherited.GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY;
34
+ return { ...inherited, ...environment };
35
+ }
@@ -11,19 +11,24 @@ export class CodexSessionRuntime {
11
11
  onServerRequest;
12
12
  onProcessExit;
13
13
  resolveSkills;
14
- constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills) {
14
+ planStatusSource;
15
+ onPlanStatus;
16
+ constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus) {
15
17
  this.launch = launch;
16
18
  this.processes = processes;
17
19
  this.onNotification = onNotification;
18
20
  this.onServerRequest = onServerRequest;
19
21
  this.onProcessExit = onProcessExit;
20
22
  this.resolveSkills = resolveSkills;
23
+ this.planStatusSource = planStatusSource;
24
+ this.onPlanStatus = onPlanStatus;
21
25
  }
22
26
  pendingRequests = new Map();
23
27
  exitUnsubscribers = new Map();
24
28
  threadIds = new Map();
29
+ planStatusLeases = new Map();
25
30
  async start(session, now, settings = {}) {
26
- const process = this.launch({ profile: session.profile, cwd: session.workspacePath, skillsConfig: await this.resolveSkills?.(session) });
31
+ const process = await this.launchForSession(session);
27
32
  try {
28
33
  process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
29
34
  process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
@@ -46,6 +51,7 @@ export class CodexSessionRuntime {
46
51
  }
47
52
  catch (error) {
48
53
  process.close();
54
+ this.releasePlanStatus(session.id);
49
55
  throw error;
50
56
  }
51
57
  }
@@ -55,6 +61,7 @@ export class CodexSessionRuntime {
55
61
  this.processes.get(sessionId)?.close();
56
62
  this.processes.delete(sessionId);
57
63
  this.threadIds.delete(sessionId);
64
+ this.releasePlanStatus(sessionId);
58
65
  }
59
66
  async release(sessionId) {
60
67
  const process = this.processes.get(sessionId);
@@ -73,6 +80,8 @@ export class CodexSessionRuntime {
73
80
  stopAll() {
74
81
  for (const sessionId of [...this.processes.keys()])
75
82
  this.stop(sessionId);
83
+ for (const sessionId of [...this.planStatusLeases.keys()])
84
+ this.releasePlanStatus(sessionId);
76
85
  }
77
86
  resolveServerRequest(sessionId, requestId, result) {
78
87
  const key = `${sessionId}:${requestId}`;
@@ -90,6 +99,7 @@ export class CodexSessionRuntime {
90
99
  const result = (await process.rpc.request('turn/start', {
91
100
  threadId: session.threadId,
92
101
  input: [{ type: 'text', text, text_elements: [] }],
102
+ ...(session.model ? { model: session.model } : {}),
93
103
  }));
94
104
  if (!result.turn?.id)
95
105
  throw new Error('CODEX_TURN_ID_MISSING');
@@ -123,7 +133,7 @@ export class CodexSessionRuntime {
123
133
  async restore(session, now) {
124
134
  if (!session.threadId)
125
135
  throw new Error('CODEX_THREAD_ID_MISSING');
126
- const process = this.launch({ profile: session.profile, cwd: session.workspacePath, skillsConfig: await this.resolveSkills?.(session) });
136
+ const process = await this.launchForSession(session);
127
137
  try {
128
138
  process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
129
139
  process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
@@ -142,6 +152,7 @@ export class CodexSessionRuntime {
142
152
  }
143
153
  catch (error) {
144
154
  process.close();
155
+ this.releasePlanStatus(session.id);
145
156
  throw error;
146
157
  }
147
158
  }
@@ -156,7 +167,37 @@ export class CodexSessionRuntime {
156
167
  this.processes.delete(sessionId);
157
168
  this.threadIds.delete(sessionId);
158
169
  this.exitUnsubscribers.delete(sessionId);
170
+ this.releasePlanStatus(sessionId);
159
171
  this.onProcessExit?.(sessionId);
160
172
  }) ?? (() => { }));
161
173
  }
174
+ async launchForSession(session) {
175
+ const lease = this.planStatusSource
176
+ ? await this.planStatusSource.open({ id: session.id, workspacePath: session.workspacePath }, (update) => this.onPlanStatus?.(session.id, update))
177
+ : undefined;
178
+ if (lease)
179
+ this.planStatusLeases.set(session.id, lease);
180
+ try {
181
+ return this.launch({
182
+ profile: session.profile,
183
+ cwd: session.workspacePath,
184
+ skillsConfig: await this.resolveSkills?.(session),
185
+ ...(lease
186
+ ? {
187
+ environment: {
188
+ GESTALT_MOBILE_ORG_PLAN_STATUS_DIRECTORY: lease.statusDirectory,
189
+ },
190
+ }
191
+ : {}),
192
+ });
193
+ }
194
+ catch (error) {
195
+ this.releasePlanStatus(session.id);
196
+ throw error;
197
+ }
198
+ }
199
+ releasePlanStatus(sessionId) {
200
+ this.planStatusLeases.get(sessionId)?.close();
201
+ this.planStatusLeases.delete(sessionId);
202
+ }
162
203
  }