gestalt-mobile 0.3.1 → 0.4.1
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 +23 -0
- package/dist/client/assets/index-B3MMCSoP.css +1 -0
- package/dist/client/assets/index-CR8XQoHz.js +11 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/app.js +8 -0
- package/dist/server/server/cli.js +47 -0
- package/dist/server/server/composition.js +26 -1
- package/dist/server/server/config.js +2 -1
- package/dist/server/server/features/skills/application/ports.js +6 -0
- package/dist/server/server/features/skills/list-available/endpoint.js +59 -0
- package/dist/server/server/features/skills/list-profiles/endpoint.js +28 -0
- package/dist/server/server/features/skills/model/errors.js +14 -0
- package/dist/server/server/features/skills/model/skill-profile.js +157 -0
- package/dist/server/server/features/skills/replace-profile/endpoint.js +29 -0
- package/dist/server/server/platform/catalog/profile-command.js +5 -2
- package/dist/server/server/platform/codex/codex-process-launcher.js +1 -1
- package/dist/server/server/platform/codex/session-runtime.js +5 -3
- package/dist/server/server/platform/skills/codex-skill-catalog.js +73 -0
- package/dist/server/server/platform/skills/filesystem-skill-profile-store.js +95 -0
- package/package.json +2 -1
- package/dist/client/assets/index-BvGdtY1N.js +0 -11
- package/dist/client/assets/index-CQd1grXV.css +0 -1
|
@@ -0,0 +1,59 @@
|
|
|
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 { z } from 'zod';
|
|
7
|
+
import { applySkillSelectionSnapshot } from '../model/skill-profile.js';
|
|
8
|
+
import { SkillProfileError } from '../model/errors.js';
|
|
9
|
+
import { problem } from '../../../platform/http/problem.js';
|
|
10
|
+
const querySchema = z.object({ workspaceId: z.string().min(1), profile: z.string().min(1) }).strict();
|
|
11
|
+
/** Register the workspace-scoped skill discovery REPR slice. */
|
|
12
|
+
export function registerListAvailableSkills(app, deps) {
|
|
13
|
+
app.get('/api/skills', async (request, reply) => {
|
|
14
|
+
const parsed = querySchema.safeParse(request.query);
|
|
15
|
+
if (!parsed.success)
|
|
16
|
+
return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_REQUEST', 400, 'workspaceId and profile are required.'));
|
|
17
|
+
try {
|
|
18
|
+
const [workspace, profile] = await Promise.all([
|
|
19
|
+
deps.workspaces.resolve(parsed.data.workspaceId),
|
|
20
|
+
deps.profiles.require(parsed.data.profile),
|
|
21
|
+
]);
|
|
22
|
+
const [discovered, project] = await Promise.all([
|
|
23
|
+
deps.catalog(profile.name).list(workspace.realPath),
|
|
24
|
+
deps.selections.readWorkspaceDefault(workspace.realPath),
|
|
25
|
+
]);
|
|
26
|
+
const skills = project
|
|
27
|
+
? applySkillSelectionSnapshot(discovered.skills, project.skills)
|
|
28
|
+
: discovered.skills;
|
|
29
|
+
return {
|
|
30
|
+
source: project ? 'project' : 'native',
|
|
31
|
+
errors: discovered.errors.map(({ message }) => ({ message })),
|
|
32
|
+
skills: [...skills]
|
|
33
|
+
.sort((left, right) => left.path.localeCompare(right.path))
|
|
34
|
+
.map((skill) => ({
|
|
35
|
+
name: skill.name,
|
|
36
|
+
description: skill.description,
|
|
37
|
+
shortDescription: skill.shortDescription,
|
|
38
|
+
displayName: skill.interface?.displayName,
|
|
39
|
+
interfaceShortDescription: skill.interface?.shortDescription,
|
|
40
|
+
iconSmall: skill.interface?.iconSmall,
|
|
41
|
+
iconLarge: skill.interface?.iconLarge,
|
|
42
|
+
brandColor: skill.interface?.brandColor,
|
|
43
|
+
defaultPrompt: skill.interface?.defaultPrompt,
|
|
44
|
+
dependencies: skill.dependencies,
|
|
45
|
+
path: skill.path,
|
|
46
|
+
scope: skill.scope,
|
|
47
|
+
nativeEnabled: discovered.skills.find((native) => native.path === skill.path)?.enabled ?? false,
|
|
48
|
+
effectiveEnabled: skill.enabled,
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof Error && (error.message === 'WORKSPACE_NOT_FOUND' || error.message === 'PROFILE_NOT_FOUND'))
|
|
54
|
+
return reply.code(404).type('application/problem+json').send(problem(error.message, 404, 'The requested catalog entry was not found.'));
|
|
55
|
+
const code = error instanceof SkillProfileError && error.code === 'INVALID_SKILL_PROFILE' ? 'INVALID_SKILL_PROFILE' : 'SKILL_DISCOVERY_FAILED';
|
|
56
|
+
return reply.code(502).type('application/problem+json').send(problem(code, 502, 'Skill discovery could not be completed.', true));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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 registerListSkillProfiles(app, deps) {
|
|
8
|
+
app.get('/api/skill-profiles', async (_request, reply) => {
|
|
9
|
+
try {
|
|
10
|
+
const names = await deps.listGlobalProfileNames();
|
|
11
|
+
const profiles = await Promise.all(names.sort((a, b) => a.localeCompare(b)).map(async (name) => {
|
|
12
|
+
try {
|
|
13
|
+
const profile = await deps.readGlobalProfile(name);
|
|
14
|
+
return profile
|
|
15
|
+
? { name: profile.name, version: profile.version, path: deps.profilePath(name), skills: profile.skills }
|
|
16
|
+
: { name, path: deps.profilePath(name), error: { code: 'INVALID_SKILL_PROFILE', message: 'Profile disappeared while reading.' } };
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return { name, path: deps.profilePath(name), error: { code: 'INVALID_SKILL_PROFILE', message: error instanceof Error ? error.message : 'Invalid profile.' } };
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
return { profiles };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return reply.code(500).type('application/problem+json').send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'Skill profiles could not be read.', true));
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -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
|
+
/** A stable, transport-independent failure in the skill selection bounded context. */
|
|
7
|
+
export class SkillProfileError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message = code) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = 'SkillProfileError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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, normalize } from 'node:path';
|
|
7
|
+
import { parseDocument, stringify } from 'yaml';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { SkillProfileError } from './errors.js';
|
|
10
|
+
const profileNamePattern = /^[a-z0-9](?:[a-z0-9-]{0,62})?$/;
|
|
11
|
+
/**
|
|
12
|
+
* A path-keyed skill record. `name` is display metadata only: a skill's path is
|
|
13
|
+
* its identity, because Codex may discover equal names in different scopes.
|
|
14
|
+
*/
|
|
15
|
+
export const skillSelectionEntrySchema = z
|
|
16
|
+
.object({
|
|
17
|
+
name: z.string().trim().min(1),
|
|
18
|
+
path: z.string().min(1),
|
|
19
|
+
enabled: z.boolean(),
|
|
20
|
+
})
|
|
21
|
+
.strict();
|
|
22
|
+
/** Metadata exposed by the stable skill catalog boundary, never protocol objects. */
|
|
23
|
+
export const availableSkillSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
name: z.string().trim().min(1),
|
|
26
|
+
path: z.string().min(1),
|
|
27
|
+
enabled: z.boolean(),
|
|
28
|
+
description: z.string().optional(),
|
|
29
|
+
shortDescription: z.string().optional(),
|
|
30
|
+
interface: z
|
|
31
|
+
.object({
|
|
32
|
+
displayName: z.string().optional(),
|
|
33
|
+
shortDescription: z.string().optional(),
|
|
34
|
+
iconSmall: z.string().optional(),
|
|
35
|
+
iconLarge: z.string().optional(),
|
|
36
|
+
brandColor: z.string().optional(),
|
|
37
|
+
defaultPrompt: z.string().optional(),
|
|
38
|
+
})
|
|
39
|
+
.optional(),
|
|
40
|
+
dependencies: z.object({ tools: z.array(z.object({ type: z.string(), value: z.string(), description: z.string().optional(), transport: z.string().optional(), command: z.string().optional(), url: z.string().optional() })).optional() }).optional(),
|
|
41
|
+
scope: z.string().optional(),
|
|
42
|
+
})
|
|
43
|
+
.strict();
|
|
44
|
+
const profileDocumentSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
version: z.literal(1),
|
|
47
|
+
name: z.string().min(1),
|
|
48
|
+
skills: z.array(skillSelectionEntrySchema),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
51
|
+
/**
|
|
52
|
+
* Normalize a profile name before using it in its global filename. The output
|
|
53
|
+
* is deliberately narrow so a profile name can never become a path traversal.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeSkillProfileName(value) {
|
|
56
|
+
const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
57
|
+
if (!profileNamePattern.test(normalized)) {
|
|
58
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Invalid skill profile name.');
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
function canonicalSkillPath(value) {
|
|
63
|
+
if (!isAbsolute(value) || normalize(value) !== value) {
|
|
64
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Skill paths must be canonical absolute paths.');
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate a complete selection and return its canonical deterministic order.
|
|
70
|
+
* This is lexical only: resolving symlinks is I/O and belongs to a platform
|
|
71
|
+
* adapter before it constructs this domain value.
|
|
72
|
+
*/
|
|
73
|
+
export function createSkillSelection(entries) {
|
|
74
|
+
const paths = new Set();
|
|
75
|
+
const validated = entries.map((entry) => {
|
|
76
|
+
const parsed = skillSelectionEntrySchema.safeParse(entry);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Invalid skill selection entry.');
|
|
79
|
+
}
|
|
80
|
+
const path = canonicalSkillPath(parsed.data.path);
|
|
81
|
+
if (paths.has(path)) {
|
|
82
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Duplicate skill selection path.');
|
|
83
|
+
}
|
|
84
|
+
paths.add(path);
|
|
85
|
+
return { ...parsed.data, path };
|
|
86
|
+
});
|
|
87
|
+
return validated.sort((left, right) => left.path.localeCompare(right.path));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Apply an optional complete selection snapshot to a fresh catalog. Selection
|
|
91
|
+
* entry names are never used for matching: canonical paths are the identity.
|
|
92
|
+
* Without a snapshot, the catalog retains Codex-native enabled states.
|
|
93
|
+
*/
|
|
94
|
+
export function applySkillSelectionSnapshot(discovered, selection) {
|
|
95
|
+
const catalog = discovered.map((skill) => {
|
|
96
|
+
const parsed = availableSkillSchema.safeParse(skill);
|
|
97
|
+
if (!parsed.success) {
|
|
98
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Invalid available skill.');
|
|
99
|
+
}
|
|
100
|
+
return { ...parsed.data, path: canonicalSkillPath(parsed.data.path) };
|
|
101
|
+
});
|
|
102
|
+
if (selection === undefined) {
|
|
103
|
+
return catalog;
|
|
104
|
+
}
|
|
105
|
+
const enabledByPath = new Map(createSkillSelection(selection).map((entry) => [entry.path, entry.enabled]));
|
|
106
|
+
return catalog.map((skill) => ({ ...skill, enabled: enabledByPath.get(skill.path) ?? false }));
|
|
107
|
+
}
|
|
108
|
+
/** Explicit session selection takes precedence over a workspace default. */
|
|
109
|
+
export function selectEffectiveSkillSelection(input) {
|
|
110
|
+
if (input.explicit !== undefined)
|
|
111
|
+
return { source: 'explicit', selection: createSkillSelection(input.explicit) };
|
|
112
|
+
if (input.project !== undefined)
|
|
113
|
+
return { source: 'project', selection: createSkillSelection(input.project) };
|
|
114
|
+
return { source: 'native', selection: undefined };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Compile a complete, process-local `skills.config` map from fresh discovery.
|
|
118
|
+
* It neither writes configuration nor mutates the user Codex installation.
|
|
119
|
+
*/
|
|
120
|
+
export function compileSkillOverride(input) {
|
|
121
|
+
const effective = selectEffectiveSkillSelection(input);
|
|
122
|
+
if (effective.selection === undefined)
|
|
123
|
+
return { source: 'native', skillsConfig: undefined, warnings: [] };
|
|
124
|
+
const discoveredPaths = new Set(input.discovered.map((skill) => canonicalSkillPath(skill.path)));
|
|
125
|
+
const warnings = effective.selection
|
|
126
|
+
.filter((entry) => !discoveredPaths.has(entry.path))
|
|
127
|
+
.map((entry) => `Saved skill path is no longer discovered: ${entry.path}`);
|
|
128
|
+
const configured = applySkillSelectionSnapshot(input.discovered, effective.selection);
|
|
129
|
+
return {
|
|
130
|
+
source: effective.source,
|
|
131
|
+
skillsConfig: configured
|
|
132
|
+
.map((skill) => ({ path: skill.path, enabled: skill.enabled }))
|
|
133
|
+
.sort((left, right) => left.path.localeCompare(right.path)),
|
|
134
|
+
warnings,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Construct the single profile contract shared by global and project YAML files. */
|
|
138
|
+
export function createSkillProfile(input) {
|
|
139
|
+
return { version: 1, name: normalizeSkillProfileName(input.name), skills: createSkillSelection(input.skills) };
|
|
140
|
+
}
|
|
141
|
+
/** Parse the version-1 YAML document without performing filesystem access. */
|
|
142
|
+
export function parseSkillProfileYaml(source) {
|
|
143
|
+
const document = parseDocument(source, { prettyErrors: false, strict: true, uniqueKeys: true });
|
|
144
|
+
if (document.errors.length > 0) {
|
|
145
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE_YAML', 'Invalid skill profile YAML.');
|
|
146
|
+
}
|
|
147
|
+
const parsed = profileDocumentSchema.safeParse(document.toJS());
|
|
148
|
+
if (!parsed.success) {
|
|
149
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Invalid skill profile document.');
|
|
150
|
+
}
|
|
151
|
+
return createSkillProfile(parsed.data);
|
|
152
|
+
}
|
|
153
|
+
/** Serialize the canonical version-1 YAML document in deterministic path order. */
|
|
154
|
+
export function serializeSkillProfileYaml(profile) {
|
|
155
|
+
const canonical = createSkillProfile(profile);
|
|
156
|
+
return stringify({ version: 1, name: canonical.name, skills: canonical.skills });
|
|
157
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 { z } from 'zod';
|
|
7
|
+
import { createSkillProfile, normalizeSkillProfileName } from '../model/skill-profile.js';
|
|
8
|
+
import { SkillProfileError } from '../model/errors.js';
|
|
9
|
+
import { problem } from '../../../platform/http/problem.js';
|
|
10
|
+
const requestSchema = z.object({ version: z.literal(1), name: z.string(), skills: z.array(z.object({ name: z.string(), path: z.string(), enabled: z.boolean() }).strict()) }).strict();
|
|
11
|
+
export function registerReplaceSkillProfile(app, deps) {
|
|
12
|
+
app.put('/api/skill-profiles/:name', async (request, reply) => {
|
|
13
|
+
const body = requestSchema.safeParse(request.body);
|
|
14
|
+
try {
|
|
15
|
+
const name = normalizeSkillProfileName(request.params.name);
|
|
16
|
+
if (!body.success || normalizeSkillProfileName(body.data.name) !== name)
|
|
17
|
+
return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_PROFILE', 400, 'The route and body profile names must match.'));
|
|
18
|
+
const profile = createSkillProfile(body.data);
|
|
19
|
+
const existed = await deps.readGlobalProfile(name);
|
|
20
|
+
await deps.replaceGlobalProfile(profile);
|
|
21
|
+
return reply.code(existed ? 200 : 201).send({ ...profile, path: deps.profilePath(name) });
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof SkillProfileError)
|
|
25
|
+
return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_PROFILE', 400, 'The skill profile could not be stored.'));
|
|
26
|
+
return reply.code(500).type('application/problem+json').send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'The skill profile could not be stored.', true));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -11,9 +11,12 @@ const localAvailability = {
|
|
|
11
11
|
codexProfileAvailable: () => !spawnSync('codex-profile', ['--version'], { stdio: 'ignore' }).error,
|
|
12
12
|
gestaltHomeExists: () => existsSync(join(homedir(), '.codex-gestalt')),
|
|
13
13
|
};
|
|
14
|
-
export function profileAppServerCommand(_profile, availability = localAvailability) {
|
|
14
|
+
export function profileAppServerCommand(_profile, availability = localAvailability, skillsConfig) {
|
|
15
15
|
void _profile;
|
|
16
|
-
|
|
16
|
+
const base = availability.codexProfileAvailable() && availability.gestaltHomeExists()
|
|
17
17
|
? { command: 'codex-profile', args: ['cli', 'gestalt', 'app-server', '--stdio'] }
|
|
18
18
|
: { command: 'codex', args: ['app-server', '--stdio'] };
|
|
19
|
+
return skillsConfig === undefined
|
|
20
|
+
? base
|
|
21
|
+
: { ...base, args: [...base.args, '--config', `skills.config = [${skillsConfig.map((entry) => `{ path = ${JSON.stringify(entry.path)}, enabled = ${entry.enabled} }`).join(', ')}]`] };
|
|
19
22
|
}
|
|
@@ -7,7 +7,7 @@ import { spawn } from 'node:child_process';
|
|
|
7
7
|
import { JsonRpcClient } from './json-rpc-client.js';
|
|
8
8
|
import { profileAppServerCommand } from '../catalog/profile-command.js';
|
|
9
9
|
export function launchCodexAppServer(input) {
|
|
10
|
-
const launch = profileAppServerCommand(input.profile);
|
|
10
|
+
const launch = profileAppServerCommand(input.profile, undefined, input.skillsConfig);
|
|
11
11
|
const child = spawn(launch.command, launch.args, {
|
|
12
12
|
cwd: input.cwd,
|
|
13
13
|
shell: false,
|
|
@@ -10,18 +10,20 @@ export class CodexSessionRuntime {
|
|
|
10
10
|
onNotification;
|
|
11
11
|
onServerRequest;
|
|
12
12
|
onProcessExit;
|
|
13
|
-
|
|
13
|
+
resolveSkills;
|
|
14
|
+
constructor(launch, processes = new Map(), onNotification, onServerRequest, onProcessExit, resolveSkills) {
|
|
14
15
|
this.launch = launch;
|
|
15
16
|
this.processes = processes;
|
|
16
17
|
this.onNotification = onNotification;
|
|
17
18
|
this.onServerRequest = onServerRequest;
|
|
18
19
|
this.onProcessExit = onProcessExit;
|
|
20
|
+
this.resolveSkills = resolveSkills;
|
|
19
21
|
}
|
|
20
22
|
pendingRequests = new Map();
|
|
21
23
|
exitUnsubscribers = new Map();
|
|
22
24
|
threadIds = new Map();
|
|
23
25
|
async start(session, now, settings = {}) {
|
|
24
|
-
const process = this.launch({ profile: session.profile, cwd: session.workspacePath });
|
|
26
|
+
const process = this.launch({ profile: session.profile, cwd: session.workspacePath, skillsConfig: await this.resolveSkills?.(session.profile, session.workspacePath) });
|
|
25
27
|
try {
|
|
26
28
|
process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
|
|
27
29
|
process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
|
|
@@ -121,7 +123,7 @@ export class CodexSessionRuntime {
|
|
|
121
123
|
async restore(session, now) {
|
|
122
124
|
if (!session.threadId)
|
|
123
125
|
throw new Error('CODEX_THREAD_ID_MISSING');
|
|
124
|
-
const process = this.launch({ profile: session.profile, cwd: session.workspacePath });
|
|
126
|
+
const process = this.launch({ profile: session.profile, cwd: session.workspacePath, skillsConfig: await this.resolveSkills?.(session.profile, session.workspacePath) });
|
|
125
127
|
try {
|
|
126
128
|
process.rpc.onNotification((notification) => this.onNotification?.(session.id, notification));
|
|
127
129
|
process.rpc.onServerRequest((request) => this.holdServerRequest(session.id, request));
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { z } from 'zod';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { availableSkillSchema } from '../../features/skills/model/skill-profile.js';
|
|
9
|
+
import { SkillProfileError } from '../../features/skills/model/errors.js';
|
|
10
|
+
import { launchCodexAppServer } from '../codex/codex-process-launcher.js';
|
|
11
|
+
const wireSkillSchema = z.object({
|
|
12
|
+
name: z.string(), description: z.string(), shortDescription: z.string().optional(),
|
|
13
|
+
interface: z.object({ displayName: z.string().optional(), shortDescription: z.string().optional(), iconSmall: z.string().optional(), iconLarge: z.string().optional(), brandColor: z.string().optional(), defaultPrompt: z.string().optional() }).optional(),
|
|
14
|
+
dependencies: z.object({ tools: z.array(z.object({ type: z.string(), value: z.string(), description: z.string().optional(), transport: z.string().optional(), command: z.string().optional(), url: z.string().optional() })).optional() }).optional(),
|
|
15
|
+
path: z.string(), scope: z.string().optional(), enabled: z.boolean(),
|
|
16
|
+
});
|
|
17
|
+
const resultSchema = z.object({ data: z.array(z.object({ cwd: z.string(), skills: z.array(wireSkillSchema), errors: z.array(z.unknown()) })) });
|
|
18
|
+
/** Short-lived Codex app-server adapter: initialize, discover, and always terminate. */
|
|
19
|
+
export class CodexSkillCatalog {
|
|
20
|
+
profile;
|
|
21
|
+
launch;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
constructor(profile, launch = launchCodexAppServer, timeoutMs = 5_000) {
|
|
24
|
+
this.profile = profile;
|
|
25
|
+
this.launch = launch;
|
|
26
|
+
this.timeoutMs = timeoutMs;
|
|
27
|
+
}
|
|
28
|
+
async list(workspace) {
|
|
29
|
+
const canonicalWorkspace = resolve(workspace);
|
|
30
|
+
const server = this.launch({ profile: this.profile, cwd: canonicalWorkspace });
|
|
31
|
+
try {
|
|
32
|
+
await this.withTimeout(server.rpc.request('initialize', { clientInfo: { name: 'gestalt-mobile' } }));
|
|
33
|
+
const result = await this.withTimeout(server.rpc.request('skills/list', { cwds: [canonicalWorkspace], forceReload: true }));
|
|
34
|
+
const parsed = resultSchema.safeParse(result);
|
|
35
|
+
if (!parsed.success)
|
|
36
|
+
throw new SkillProfileError('INVALID_SKILL_DISCOVERY', 'Invalid Codex skill catalog response.');
|
|
37
|
+
const entry = parsed.data.data.find((candidate) => candidate.cwd === canonicalWorkspace);
|
|
38
|
+
if (!entry)
|
|
39
|
+
throw new SkillProfileError('INVALID_SKILL_DISCOVERY', 'Codex did not return the requested workspace catalog.');
|
|
40
|
+
const skills = entry.skills.map((skill) => {
|
|
41
|
+
const stable = availableSkillSchema.safeParse(skill);
|
|
42
|
+
if (!stable.success)
|
|
43
|
+
throw new SkillProfileError('INVALID_SKILL_DISCOVERY', 'Invalid Codex skill metadata.');
|
|
44
|
+
return stable.data;
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
skills,
|
|
48
|
+
errors: entry.errors.map((error) => ({ message: typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ? error.message : 'Codex skill discovery error.' })),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof SkillProfileError)
|
|
53
|
+
throw error;
|
|
54
|
+
throw new SkillProfileError('INVALID_SKILL_DISCOVERY', 'Codex skill discovery failed.');
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
server.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async withTimeout(promise) {
|
|
61
|
+
let timer;
|
|
62
|
+
try {
|
|
63
|
+
return await Promise.race([
|
|
64
|
+
promise,
|
|
65
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), this.timeoutMs); }),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
if (timer)
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdir, readdir, rename, rm, writeFile, lstat, readFile, realpath } from 'node:fs/promises';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { normalizeSkillProfileName, parseSkillProfileYaml, serializeSkillProfileYaml, } from '../../features/skills/model/skill-profile.js';
|
|
10
|
+
import { SkillProfileError } from '../../features/skills/model/errors.js';
|
|
11
|
+
function missing(error) {
|
|
12
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
13
|
+
}
|
|
14
|
+
/** Filesystem adapter with injected home directory; no process-global home lookup. */
|
|
15
|
+
export class FilesystemSkillProfileStore {
|
|
16
|
+
homeDirectory;
|
|
17
|
+
constructor(homeDirectory) {
|
|
18
|
+
this.homeDirectory = homeDirectory;
|
|
19
|
+
}
|
|
20
|
+
/** Absolute canonical location of one normalized global profile. */
|
|
21
|
+
globalProfilePath(name) {
|
|
22
|
+
return join(resolve(this.homeDirectory), '.gestalt', 'skill-profiles', `${normalizeSkillProfileName(name)}.yml`);
|
|
23
|
+
}
|
|
24
|
+
async listGlobalProfileNames() {
|
|
25
|
+
const root = await this.globalRoot();
|
|
26
|
+
try {
|
|
27
|
+
const entries = await readdir(root, { withFileTypes: true, encoding: 'utf8' });
|
|
28
|
+
return entries
|
|
29
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.yml'))
|
|
30
|
+
.map((entry) => entry.name.slice(0, -4))
|
|
31
|
+
.filter((name) => {
|
|
32
|
+
try {
|
|
33
|
+
return normalizeSkillProfileName(name) === name;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.sort((left, right) => left.localeCompare(right));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (missing(error))
|
|
43
|
+
return [];
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async readGlobalProfile(name) {
|
|
48
|
+
const path = this.globalProfilePath(name);
|
|
49
|
+
return this.readProfile(path);
|
|
50
|
+
}
|
|
51
|
+
async replaceGlobalProfile(profile) {
|
|
52
|
+
const name = normalizeSkillProfileName(profile.name);
|
|
53
|
+
const root = await this.globalRoot();
|
|
54
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
55
|
+
const destination = join(root, `${name}.yml`);
|
|
56
|
+
const temporary = join(root, `.${name}.${randomUUID()}.tmp`);
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(temporary, serializeSkillProfileYaml(profile), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
59
|
+
await rename(temporary, destination);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async readWorkspaceDefault(workspace) {
|
|
66
|
+
const root = await realpath(workspace);
|
|
67
|
+
const path = resolve(root, 'gestalt-skills.yml');
|
|
68
|
+
if (dirname(path) !== root) {
|
|
69
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Workspace profile escaped its root.');
|
|
70
|
+
}
|
|
71
|
+
return this.readProfile(path);
|
|
72
|
+
}
|
|
73
|
+
async globalRoot() {
|
|
74
|
+
const home = resolve(this.homeDirectory);
|
|
75
|
+
const root = resolve(home, '.gestalt', 'skill-profiles');
|
|
76
|
+
if (!root.startsWith(`${home}/`)) {
|
|
77
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Global profile root escaped home.');
|
|
78
|
+
}
|
|
79
|
+
return root;
|
|
80
|
+
}
|
|
81
|
+
async readProfile(path) {
|
|
82
|
+
try {
|
|
83
|
+
const stat = await lstat(path);
|
|
84
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
85
|
+
throw new SkillProfileError('INVALID_SKILL_PROFILE', 'Profile must be a regular file.');
|
|
86
|
+
}
|
|
87
|
+
return parseSkillProfileYaml(await readFile(path, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (missing(error))
|
|
91
|
+
return undefined;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gestalt-mobile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Mobile-first web relay for durable Codex development sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"codex",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"@fastify/static": "^10.1.0",
|
|
56
56
|
"fastify": "^5.10.0",
|
|
57
57
|
"ws": "^8.21.0",
|
|
58
|
+
"yaml": "^2.9.0",
|
|
58
59
|
"zod": "^4.4.3"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|