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.
- package/README.md +7 -0
- package/dist/client/assets/index-CGAy94nY.js +12 -0
- package/dist/client/assets/index-DSU_Fcvo.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +19 -3
- package/dist/server/server/composition.js +49 -8
- package/dist/server/server/features/catalog/get-bootstrap/use-case.js +6 -1
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +242 -0
- package/dist/server/server/features/plans/application/ports.js +6 -0
- package/dist/server/server/features/plans/application/supervised-plan-registry.js +22 -0
- package/dist/server/server/features/plans/close-plan/endpoint.js +33 -0
- package/dist/server/server/features/plans/domain/supervised-plan.js +6 -0
- package/dist/server/server/features/plans/get-plan/endpoint.js +14 -0
- package/dist/server/server/features/sessions/application/start-settings.js +2 -1
- package/dist/server/server/features/sessions/model/relay-session.js +20 -0
- package/dist/server/server/features/sessions/select-model/endpoint.js +23 -0
- package/dist/server/server/features/sessions/start-session/endpoint.js +6 -0
- package/dist/server/server/features/sessions/start-session/request.js +1 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +32 -1
- package/dist/server/server/features/skills/delete-profile/endpoint.js +24 -0
- package/dist/server/server/features/skills/list-available/endpoint.js +4 -2
- package/dist/server/server/platform/codex/codex-model-catalog.js +63 -0
- package/dist/server/server/platform/codex/codex-process-launcher.js +8 -0
- package/dist/server/server/platform/codex/session-runtime.js +44 -3
- package/dist/server/server/platform/persistence/migrate.js +8 -1
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +11 -2
- package/dist/server/server/platform/plans/filesystem-plan-status-source.js +301 -0
- package/dist/server/server/platform/skills/cached-skill-catalog.js +42 -0
- package/dist/server/server/platform/skills/codex-skill-catalog.js +18 -5
- package/dist/server/server/platform/skills/filesystem-skill-profile-store.js +28 -0
- package/package.json +3 -1
- package/dist/client/assets/index-B3MMCSoP.css +0 -1
- 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
|
|
54
|
-
|
|
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:
|
|
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
|
-
|
|
251
|
-
|
|
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([
|
|
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,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,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
|
+
}
|
|
@@ -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)
|
|
@@ -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
|
|
54
|
+
model,
|
|
24
55
|
sandbox: input.sandbox,
|
|
25
56
|
approvalPolicy: input.approvalPolicy,
|
|
26
57
|
});
|