gestalt-mobile 0.4.1 → 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.
Files changed (33) hide show
  1. package/README.md +7 -0
  2. package/dist/client/assets/index-CGAy94nY.js +12 -0
  3. package/dist/client/assets/index-DSU_Fcvo.css +1 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +19 -3
  6. package/dist/server/server/composition.js +49 -8
  7. package/dist/server/server/features/catalog/get-bootstrap/use-case.js +6 -1
  8. package/dist/server/server/features/plans/application/parse-supervised-plan.js +242 -0
  9. package/dist/server/server/features/plans/application/ports.js +6 -0
  10. package/dist/server/server/features/plans/application/supervised-plan-registry.js +22 -0
  11. package/dist/server/server/features/plans/close-plan/endpoint.js +33 -0
  12. package/dist/server/server/features/plans/domain/supervised-plan.js +6 -0
  13. package/dist/server/server/features/plans/get-plan/endpoint.js +14 -0
  14. package/dist/server/server/features/sessions/application/start-settings.js +2 -1
  15. package/dist/server/server/features/sessions/model/relay-session.js +20 -0
  16. package/dist/server/server/features/sessions/select-model/endpoint.js +23 -0
  17. package/dist/server/server/features/sessions/start-session/endpoint.js +6 -0
  18. package/dist/server/server/features/sessions/start-session/request.js +1 -0
  19. package/dist/server/server/features/sessions/start-session/use-case.js +32 -1
  20. package/dist/server/server/features/skills/delete-profile/endpoint.js +24 -0
  21. package/dist/server/server/features/skills/list-available/endpoint.js +4 -2
  22. package/dist/server/server/platform/codex/codex-model-catalog.js +63 -0
  23. package/dist/server/server/platform/codex/codex-process-launcher.js +8 -0
  24. package/dist/server/server/platform/codex/session-runtime.js +44 -3
  25. package/dist/server/server/platform/persistence/migrate.js +8 -1
  26. package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -2
  27. package/dist/server/server/platform/plans/filesystem-plan-status-source.js +301 -0
  28. package/dist/server/server/platform/skills/cached-skill-catalog.js +42 -0
  29. package/dist/server/server/platform/skills/codex-skill-catalog.js +18 -5
  30. package/dist/server/server/platform/skills/filesystem-skill-profile-store.js +28 -0
  31. package/package.json +3 -1
  32. package/dist/client/assets/index-B3MMCSoP.css +0 -1
  33. package/dist/client/assets/index-CR8XQoHz.js +0 -11
@@ -6,11 +6,12 @@
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
- import { join, resolve } from 'node:path';
9
+ import { dirname, join, resolve } from 'node:path';
10
10
  import { buildApp } from './app.js';
11
11
  import { FilesystemWorkspaceCatalog } from './platform/catalog/filesystem-workspace-catalog.js';
12
12
  import { protocolCompatibility } from './platform/codex/protocol-compatibility.js';
13
13
  import { launchCodexAppServer } from './platform/codex/codex-process-launcher.js';
14
+ import { CodexModelCatalog } from './platform/codex/codex-model-catalog.js';
14
15
  import { createRecentThreadLister } from './platform/codex/recent-thread-lister.js';
15
16
  import { CodexSessionRuntime } from './platform/codex/session-runtime.js';
16
17
  import { normalizeCodexNotification } from './platform/codex/normalizer.js';
@@ -33,7 +34,10 @@ import { isValidInteractionResponse } from './features/sessions/interaction/resp
33
34
  import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
34
35
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
35
36
  import { CodexSkillCatalog } from './platform/skills/codex-skill-catalog.js';
37
+ import { CachedSkillCatalog } from './platform/skills/cached-skill-catalog.js';
36
38
  import { compileSkillOverride } from './features/skills/model/skill-profile.js';
39
+ import { SupervisedPlanRegistry } from './features/plans/application/supervised-plan-registry.js';
40
+ import { FilesystemPlanStatusSource } from './platform/plans/filesystem-plan-status-source.js';
37
41
  const generatedProtocolVersion = 'codex-cli 0.144.3';
38
42
  export async function composeRelayApp(options) {
39
43
  const root = resolve(options.root);
@@ -46,15 +50,25 @@ export async function composeRelayApp(options) {
46
50
  const journal = new SqliteEventJournal(database);
47
51
  const interactions = new SqlitePendingInteractionStore(database);
48
52
  const idempotency = new SqliteIdempotencyStore(database);
53
+ const supervisedPlans = new SupervisedPlanRegistry();
54
+ const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
49
55
  const withPendingInteractions = (session) => (session ? { ...session, pendingInteractions: interactions.list(session.id) } : null);
50
56
  const events = new SessionEventBus();
51
57
  const workspaces = new FilesystemWorkspaceCatalog(root);
58
+ const models = new CodexModelCatalog(root, options.launchAppServer ?? launchCodexAppServer);
52
59
  const skillProfiles = new FilesystemSkillProfileStore(options.homeDirectory ?? homedir());
53
- const resolveSkills = async (profile, cwd) => {
54
- const project = await skillProfiles.readWorkspaceDefault(cwd);
60
+ const skillCatalog = (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer);
61
+ const editorSkillCatalog = new CachedSkillCatalog((profile, workspace) => skillCatalog(profile).list(workspace));
62
+ const resolveSkills = async (session) => {
63
+ const catalog = await skillCatalog(session.profile).list(session.workspacePath);
64
+ if (session.effectiveSkillSelection)
65
+ return compileSkillOverride({
66
+ discovered: catalog.skills,
67
+ explicit: session.effectiveSkillSelection.skills,
68
+ }).skillsConfig;
69
+ const project = await skillProfiles.readWorkspaceDefault(session.workspacePath);
55
70
  if (!options.explicitSkillProfile && !project)
56
71
  return undefined;
57
- const catalog = await new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer).list(cwd);
58
72
  return compileSkillOverride({
59
73
  discovered: catalog.skills,
60
74
  explicit: options.explicitSkillProfile?.skills,
@@ -93,7 +107,13 @@ export async function composeRelayApp(options) {
93
107
  sessions.save(updated);
94
108
  events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
95
109
  return true;
96
- }, (sessionId) => recoverExitedSession(sessionId), resolveSkills)
110
+ }, (sessionId) => recoverExitedSession(sessionId), resolveSkills, planStatusSource, (sessionId, update) => {
111
+ supervisedPlans.accept(sessionId, update);
112
+ if (update.kind === 'updated') {
113
+ const occurredAt = new Date().toISOString();
114
+ events.publish(journal.append(sessionId, 'plan.updated', update.plan, occurredAt));
115
+ }
116
+ })
97
117
  : null;
98
118
  const saveSession = (session) => {
99
119
  sessions.save(session);
@@ -131,11 +151,12 @@ export async function composeRelayApp(options) {
131
151
  skills: {
132
152
  workspaces,
133
153
  profiles: options.profiles,
134
- catalog: (profile) => new CodexSkillCatalog(profile, options.launchAppServer ?? launchCodexAppServer),
154
+ catalog: editorSkillCatalog,
135
155
  selections: skillProfiles,
136
156
  listGlobalProfileNames: () => skillProfiles.listGlobalProfileNames(),
137
157
  readGlobalProfile: (name) => skillProfiles.readGlobalProfile(name),
138
158
  replaceGlobalProfile: (profile) => skillProfiles.replaceGlobalProfile(profile),
159
+ deleteGlobalProfile: (name) => skillProfiles.deleteGlobalProfile(name),
139
160
  profilePath: (name) => skillProfiles.globalProfilePath(name),
140
161
  },
141
162
  logger: console,
@@ -144,6 +165,7 @@ export async function composeRelayApp(options) {
144
165
  bootstrap: {
145
166
  workspaces,
146
167
  profiles: options.profiles,
168
+ models,
147
169
  sessions: {
148
170
  list: () => sessions.list().map((session) => withPendingInteractions(session)),
149
171
  },
@@ -157,12 +179,16 @@ export async function composeRelayApp(options) {
157
179
  list: () => sessions.list().map((session) => withPendingInteractions(session)),
158
180
  workspaces,
159
181
  profiles: options.profiles,
182
+ skillProfiles,
183
+ skillCatalog,
184
+ defaultSkillProfile: options.explicitSkillProfile,
160
185
  activate: runtime
161
186
  ? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
162
187
  : undefined,
163
188
  startTurn: runtime
164
189
  ? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
165
190
  : undefined,
191
+ models,
166
192
  readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
167
193
  currentSequence: (sessionId) => journal.since(sessionId, 0).at(-1)?.sequence ?? 0,
168
194
  interruptTurn: runtime
@@ -196,6 +222,16 @@ export async function composeRelayApp(options) {
196
222
  since: (id, after) => journal.since(id, after),
197
223
  subscribe: (id, listener) => events.subscribe(id, listener),
198
224
  },
225
+ planRoutes: {
226
+ exists: (id) => sessions.find(id) !== null,
227
+ find: (id) => supervisedPlans.find(id),
228
+ removeStatus: (id) => planStatusSource.remove(id, supervisedPlans.identity(id) ?? undefined),
229
+ clear: (id) => supervisedPlans.clear(id),
230
+ closed: (id) => {
231
+ const occurredAt = new Date().toISOString();
232
+ events.publish(journal.append(id, 'plan.closed', {}, occurredAt));
233
+ },
234
+ },
199
235
  interactions: {
200
236
  resolve: (sessionId, requestId, resolvedAt) => interactions.resolve(sessionId, requestId, resolvedAt),
201
237
  validate: (sessionId, requestId, value) => {
@@ -247,10 +283,15 @@ export async function composeRelayApp(options) {
247
283
  }
248
284
  });
249
285
  };
250
- if (runtime)
251
- app.addHook('onListen', () => void restoreActiveSessions());
286
+ app.addHook('onListen', async () => {
287
+ const profile = (await options.profiles.list()).find((item) => item.state === 'ok')?.name;
288
+ if (profile)
289
+ await editorSkillCatalog.refresh(profile, root);
290
+ await restoreActiveSessions();
291
+ });
252
292
  app.addHook('onClose', async () => {
253
293
  runtime?.stopAll();
294
+ planStatusSource.closeAll();
254
295
  database.close();
255
296
  });
256
297
  return app;
@@ -4,10 +4,15 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  export async function getBootstrap(deps) {
7
- const [workspaces, profiles] = await Promise.all([deps.workspaces.list(), deps.profiles.list()]);
7
+ const [workspaces, profiles, models] = await Promise.all([
8
+ deps.workspaces.list(),
9
+ deps.profiles.list(),
10
+ deps.models?.list().catch(() => []) ?? [],
11
+ ]);
8
12
  return {
9
13
  workspaces,
10
14
  profiles,
15
+ models,
11
16
  sessions: deps.sessions.list(),
12
17
  capabilities: {
13
18
  approvals: true,
@@ -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
+ }
@@ -3,4 +3,5 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export {};
6
+ /** The relay's configured default, kept explicit so deployments can change it centrally later. */
7
+ export const DEFAULT_SESSION_MODEL = 'gpt-5.6-terra';
@@ -4,7 +4,16 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { DomainError } from './errors.js';
7
+ import { createSkillSelection, normalizeSkillProfileName, } from '../../skills/model/skill-profile.js';
7
8
  import { interactionId, profileName, sessionId, threadId, turnId, workspaceId, workspacePath, } from './value-objects.js';
9
+ export function createEffectiveSkillSelection(input) {
10
+ return {
11
+ ...(input.selectedProfileName === undefined
12
+ ? {}
13
+ : { selectedProfileName: normalizeSkillProfileName(input.selectedProfileName) }),
14
+ skills: createSkillSelection(input.skills),
15
+ };
16
+ }
8
17
  export class RelaySession {
9
18
  value;
10
19
  events;
@@ -18,6 +27,9 @@ export class RelaySession {
18
27
  workspaceId: workspaceId(input.workspaceId),
19
28
  workspacePath: workspacePath(input.workspacePath),
20
29
  profile: profileName(input.profile),
30
+ ...(input.model === undefined ? {} : { model: input.model }),
31
+ ...(input.branch === undefined ? {} : { branch: input.branch }),
32
+ effectiveSkillSelection: createEffectiveSkillSelection(input.effectiveSkillSelection),
21
33
  threadId: null,
22
34
  state: 'starting',
23
35
  desiredState: 'active',
@@ -55,6 +67,11 @@ export class RelaySession {
55
67
  bindThread(value, now) {
56
68
  return this.transition({ threadId: threadId(value), state: 'ready' }, 'ThreadBound', now);
57
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
+ }
58
75
  startTurn(value, now) {
59
76
  if (this.value.state === 'turnActive')
60
77
  throw new DomainError('SESSION_TURN_ACTIVE');
@@ -117,6 +134,9 @@ export class RelaySession {
117
134
  function copy(snapshot) {
118
135
  return {
119
136
  ...snapshot,
137
+ ...(snapshot.effectiveSkillSelection === undefined
138
+ ? {}
139
+ : { effectiveSkillSelection: createEffectiveSkillSelection(snapshot.effectiveSkillSelection) }),
120
140
  pendingInteractions: snapshot.pendingInteractions.map((item) => ({ ...item })),
121
141
  };
122
142
  }
@@ -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
+ }
@@ -6,6 +6,8 @@
6
6
  import { idempotencyKey } from '../../../platform/http/idempotency.js';
7
7
  import { parseStartSessionRequest } from './request.js';
8
8
  import { startSession } from './use-case.js';
9
+ import { SkillProfileError } from '../../skills/model/errors.js';
10
+ import { problem } from '../../../platform/http/problem.js';
9
11
  export function registerStartSession(app, deps) {
10
12
  app.post('/api/sessions', async (request, reply) => {
11
13
  const key = idempotencyKey(request.headers);
@@ -28,6 +30,10 @@ export function registerStartSession(app, deps) {
28
30
  }
29
31
  catch (error) {
30
32
  deps.reportFailure?.('start-session', error);
33
+ if (error instanceof SkillProfileError)
34
+ return reply.code(400).type('application/problem+json').send(problem(error.code, 400, error.code === 'UNKNOWN_SKILL_PROFILE'
35
+ ? 'The selected skill profile does not exist.'
36
+ : 'The selected skill profile is invalid.'));
31
37
  throw error;
32
38
  }
33
39
  if (key)
@@ -7,6 +7,7 @@ import { z } from 'zod';
7
7
  const schema = z.object({
8
8
  workspaceId: z.string().min(1),
9
9
  profile: z.string().min(1),
10
+ skillProfile: z.string().trim().min(1).optional(),
10
11
  model: z
11
12
  .string()
12
13
  .trim()
@@ -4,23 +4,54 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
+ import { DEFAULT_SESSION_MODEL, } from '../application/start-settings.js';
8
+ import { applySkillSelectionSnapshot, } from '../../skills/model/skill-profile.js';
9
+ import { SkillProfileError } from '../../skills/model/errors.js';
7
10
  export async function startSession(input, deps) {
11
+ const model = input.model ?? DEFAULT_SESSION_MODEL;
8
12
  const [workspace] = await Promise.all([
9
13
  deps.workspaces.resolve(input.workspaceId),
10
14
  deps.profiles.require(input.profile),
11
15
  ]);
16
+ if (deps.models) {
17
+ const models = await deps.models.list();
18
+ if (!models.includes(model))
19
+ throw new Error('CODEX_MODEL_UNAVAILABLE');
20
+ }
21
+ const selectedProfile = input.skillProfile
22
+ ? await deps.skillProfiles.readGlobalProfile(input.skillProfile)
23
+ : deps.defaultSkillProfile;
24
+ if (input.skillProfile && !selectedProfile)
25
+ throw new SkillProfileError('UNKNOWN_SKILL_PROFILE', 'The selected skill profile does not exist.');
26
+ const [projectProfile, catalog] = await Promise.all([
27
+ deps.skillProfiles.readWorkspaceDefault(workspace.realPath),
28
+ deps.skillCatalog(input.profile).list(workspace.realPath),
29
+ ]);
30
+ const sourceProfile = selectedProfile ?? projectProfile;
31
+ const effectiveSkillSelection = {
32
+ ...(selectedProfile ? { selectedProfileName: selectedProfile.name } : {}),
33
+ skills: applySkillSelectionSnapshot(catalog.skills, sourceProfile?.skills).map((skill) => ({
34
+ name: skill.name,
35
+ path: skill.path,
36
+ enabled: skill.enabled,
37
+ })),
38
+ };
39
+ const branch = await deps.gitBranch?.(workspace.realPath);
12
40
  const session = RelaySession.create({
13
41
  id: deps.createId(),
14
42
  workspaceId: workspace.id,
15
43
  workspacePath: workspace.realPath,
16
44
  profile: input.profile,
45
+ model,
46
+ ...(branch ? { branch } : {}),
47
+ effectiveSkillSelection,
17
48
  now: deps.now(),
18
49
  }).snapshot;
19
50
  deps.save(session);
20
51
  if (!deps.activate)
21
52
  return session;
22
53
  const active = await deps.activate(session, {
23
- model: input.model,
54
+ model,
24
55
  sandbox: input.sandbox,
25
56
  approvalPolicy: input.approvalPolicy,
26
57
  });