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
@@ -0,0 +1,24 @@
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 { SkillProfileError } from '../model/errors.js';
7
+ import { normalizeSkillProfileName } from '../model/skill-profile.js';
8
+ import { problem } from '../../../platform/http/problem.js';
9
+ /** Registers the profile-management deletion command; it has no session dependency. */
10
+ export function registerDeleteSkillProfile(app, deps) {
11
+ app.delete('/api/skill-profiles/:name', async (request, reply) => {
12
+ try {
13
+ const name = normalizeSkillProfileName(request.params.name);
14
+ if (!(await deps.deleteGlobalProfile(name)))
15
+ return reply.code(404).type('application/problem+json').send(problem('SKILL_PROFILE_NOT_FOUND', 404, 'The skill profile was not found.'));
16
+ return reply.code(204).send();
17
+ }
18
+ catch (error) {
19
+ if (error instanceof SkillProfileError)
20
+ return reply.code(400).type('application/problem+json').send(problem('INVALID_SKILL_PROFILE', 400, 'The skill profile could not be deleted.'));
21
+ return reply.code(500).type('application/problem+json').send(problem('SKILL_PROFILE_PERSISTENCE_FAILED', 500, 'The skill profile could not be deleted.', true));
22
+ }
23
+ });
24
+ }
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  import { applySkillSelectionSnapshot } from '../model/skill-profile.js';
8
8
  import { SkillProfileError } from '../model/errors.js';
9
9
  import { problem } from '../../../platform/http/problem.js';
10
- const querySchema = z.object({ workspaceId: z.string().min(1), profile: z.string().min(1) }).strict();
10
+ const querySchema = z.object({ workspaceId: z.string().min(1), profile: z.string().min(1), refresh: z.enum(['true']).optional() }).strict();
11
11
  /** Register the workspace-scoped skill discovery REPR slice. */
12
12
  export function registerListAvailableSkills(app, deps) {
13
13
  app.get('/api/skills', async (request, reply) => {
@@ -20,7 +20,9 @@ export function registerListAvailableSkills(app, deps) {
20
20
  deps.profiles.require(parsed.data.profile),
21
21
  ]);
22
22
  const [discovered, project] = await Promise.all([
23
- deps.catalog(profile.name).list(workspace.realPath),
23
+ parsed.data.refresh
24
+ ? deps.catalog.refresh(profile.name, workspace.realPath)
25
+ : deps.catalog.list(profile.name, workspace.realPath),
24
26
  deps.selections.readWorkspaceDefault(workspace.realPath),
25
27
  ]);
26
28
  const skills = project
@@ -0,0 +1,63 @@
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 { launchCodexAppServer } from './codex-process-launcher.js';
8
+ const modelSchema = z.object({ id: z.string().min(1) });
9
+ const resultSchema = z.union([
10
+ z.object({ data: z.array(modelSchema) }),
11
+ z.object({ models: z.array(modelSchema) }),
12
+ z.object({ data: z.object({ models: z.array(modelSchema) }) }),
13
+ ]);
14
+ /** Short-lived adapter for the Codex app-server model catalog. */
15
+ export class CodexModelCatalog {
16
+ cwd;
17
+ launch;
18
+ timeoutMs;
19
+ constructor(cwd, launch = launchCodexAppServer, timeoutMs = 5_000) {
20
+ this.cwd = cwd;
21
+ this.launch = launch;
22
+ this.timeoutMs = timeoutMs;
23
+ }
24
+ async list() {
25
+ const server = this.launch({ profile: '', cwd: this.cwd });
26
+ try {
27
+ await this.withTimeout(server.rpc.request('initialize', {
28
+ clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
29
+ capabilities: null,
30
+ }));
31
+ const result = resultSchema.safeParse(await this.withTimeout(server.rpc.request('model/list', {})));
32
+ if (!result.success)
33
+ return [];
34
+ const models = 'models' in result.data
35
+ ? result.data.models
36
+ : Array.isArray(result.data.data)
37
+ ? result.data.data
38
+ : result.data.data.models;
39
+ return [...new Set(models.map((model) => model.id))].sort((left, right) => left.localeCompare(right));
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ finally {
45
+ server.close();
46
+ }
47
+ }
48
+ async withTimeout(promise) {
49
+ let timer;
50
+ try {
51
+ return await Promise.race([
52
+ promise,
53
+ new Promise((_, reject) => {
54
+ timer = setTimeout(() => reject(new Error('Codex model discovery timed out.')), this.timeoutMs);
55
+ }),
56
+ ]);
57
+ }
58
+ finally {
59
+ if (timer)
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ }
@@ -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.profile, session.workspacePath) });
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.profile, session.workspacePath) });
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
  }
@@ -3,7 +3,14 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, resolved_at TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
6
+ const schema = `CREATE TABLE IF NOT EXISTS relay_sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, workspace_path TEXT NOT NULL, profile TEXT NOT NULL, model TEXT, branch TEXT, thread_id TEXT, state TEXT NOT NULL, desired_state TEXT NOT NULL, active_turn_id TEXT, protocol_version TEXT, failure_count INTEGER NOT NULL DEFAULT 0, effective_skill_selection_json TEXT, next_sequence INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS pending_interactions (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, request_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL, resolved_at TEXT, PRIMARY KEY (session_id, request_id)); CREATE TABLE IF NOT EXISTS session_events (session_id TEXT NOT NULL REFERENCES relay_sessions(id) ON DELETE CASCADE, sequence INTEGER NOT NULL, occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY (session_id, sequence)); CREATE TABLE IF NOT EXISTS idempotency_results (scope TEXT NOT NULL, key TEXT NOT NULL, status_code INTEGER NOT NULL, body_json TEXT NOT NULL, PRIMARY KEY (scope, key));`;
7
7
  export function migrate(database) {
8
8
  database.exec(schema);
9
+ const columns = database.prepare('PRAGMA table_info(relay_sessions)').all();
10
+ if (!columns.some((column) => column.name === 'effective_skill_selection_json'))
11
+ database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
12
+ if (!columns.some((column) => column.name === 'model'))
13
+ database.exec('ALTER TABLE relay_sessions ADD COLUMN model TEXT');
14
+ if (!columns.some((column) => column.name === 'branch'))
15
+ database.exec('ALTER TABLE relay_sessions ADD COLUMN branch TEXT');
9
16
  }
@@ -3,6 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { createEffectiveSkillSelection, } from '../../features/sessions/model/relay-session.js';
6
7
  export class SqliteSessionRepository {
7
8
  db;
8
9
  constructor(db) {
@@ -10,8 +11,10 @@ export class SqliteSessionRepository {
10
11
  }
11
12
  save(session) {
12
13
  this.db
13
- .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,thread_id,state,desired_state,active_turn_id,protocol_version,failure_count,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,profile=excluded.profile,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,failure_count=excluded.failure_count,updated_at=excluded.updated_at')
14
- .run(session.id, session.workspaceId, session.workspacePath, session.profile, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.failureCount, session.createdAt, session.updatedAt);
14
+ .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,profile,model,branch,thread_id,state,desired_state,active_turn_id,protocol_version,failure_count,effective_skill_selection_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,profile=excluded.profile,model=excluded.model,branch=excluded.branch,thread_id=excluded.thread_id,state=excluded.state,desired_state=excluded.desired_state,active_turn_id=excluded.active_turn_id,protocol_version=excluded.protocol_version,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,updated_at=excluded.updated_at')
15
+ .run(session.id, session.workspaceId, session.workspacePath, session.profile, session.model ?? null, session.branch ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.failureCount, session.effectiveSkillSelection === undefined
16
+ ? null
17
+ : JSON.stringify(session.effectiveSkillSelection), session.createdAt, session.updatedAt);
15
18
  }
16
19
  find(id) {
17
20
  const row = this.db.prepare('SELECT * FROM relay_sessions WHERE id = ?').get(id);
@@ -25,17 +28,23 @@ export class SqliteSessionRepository {
25
28
  }
26
29
  }
27
30
  function map(row) {
31
+ const effectiveSkillSelection = row.effective_skill_selection_json
32
+ ? createEffectiveSkillSelection(JSON.parse(row.effective_skill_selection_json))
33
+ : undefined;
28
34
  return {
29
35
  id: row.id,
30
36
  workspaceId: row.workspace_id,
31
37
  workspacePath: row.workspace_path,
32
38
  profile: row.profile,
39
+ ...(row.model === null ? {} : { model: row.model }),
40
+ ...(row.branch === null ? {} : { branch: row.branch }),
33
41
  threadId: row.thread_id,
34
42
  state: row.state,
35
43
  desiredState: row.desired_state,
36
44
  activeTurnId: row.active_turn_id,
37
45
  protocolVersion: row.protocol_version,
38
46
  failureCount: row.failure_count,
47
+ ...(effectiveSkillSelection === undefined ? {} : { effectiveSkillSelection }),
39
48
  pendingInteractions: [],
40
49
  createdAt: row.created_at,
41
50
  updatedAt: row.updated_at,
@@ -0,0 +1,301 @@
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 { chmod, mkdir, readFile, readdir, rename, rm, stat, watch, writeFile, realpath, } from 'node:fs/promises';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
+ import { basename, dirname, join } from 'node:path';
9
+ import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
10
+ import { isPlanPathWithinWorkspace } from '../../features/plans/application/parse-supervised-plan.js';
11
+ const statusFileSuffix = '.plan-status.json';
12
+ export class FilesystemPlanStatusSource {
13
+ dismissalDirectory;
14
+ dismissalFilesystem;
15
+ planReadFilesystem;
16
+ statusRemovalFilesystem;
17
+ leases = new Map();
18
+ activeStatusPaths = new Map();
19
+ constructor(dismissalDirectory, dismissalFilesystem = { writeFile, rename }, planReadFilesystem = { readFile, realpath }, statusRemovalFilesystem = { rm }) {
20
+ this.dismissalDirectory = dismissalDirectory;
21
+ this.dismissalFilesystem = dismissalFilesystem;
22
+ this.planReadFilesystem = planReadFilesystem;
23
+ this.statusRemovalFilesystem = statusRemovalFilesystem;
24
+ }
25
+ async open(session, listener) {
26
+ this.leases.get(session.id)?.close();
27
+ const statusDirectory = planStatusDirectoryPath(session.workspacePath, session.id);
28
+ await mkdir(this.dismissalDirectory, { recursive: true, mode: 0o700 });
29
+ await mkdir(statusDirectory, { recursive: true, mode: 0o700 });
30
+ if (process.platform !== 'win32') {
31
+ await Promise.all([
32
+ chmod(this.dismissalDirectory, 0o700),
33
+ chmod(statusDirectory, 0o700),
34
+ ]);
35
+ }
36
+ const lease = new ActiveLease(statusDirectory, session.id, session.workspacePath, listener, this.planReadFilesystem, (identity) => this.isDismissed(session.id, identity), (statusPath) => this.activeStatusPaths.set(session.id, statusPath), () => {
37
+ this.leases.delete(session.id);
38
+ this.activeStatusPaths.delete(session.id);
39
+ });
40
+ this.leases.set(session.id, lease);
41
+ await lease.start();
42
+ return lease;
43
+ }
44
+ closeAll() {
45
+ for (const lease of this.leases.values())
46
+ lease.close();
47
+ this.leases.clear();
48
+ }
49
+ async remove(sessionId, identity) {
50
+ const statusPath = this.activeStatusPaths.get(sessionId);
51
+ const signal = statusPath ? await this.readStatusForRollback(statusPath) : undefined;
52
+ if (statusPath)
53
+ await this.statusRemovalFilesystem.rm(statusPath, { force: true });
54
+ try {
55
+ if (identity)
56
+ await this.dismiss(sessionId, identity);
57
+ }
58
+ catch (error) {
59
+ if (signal !== undefined && statusPath)
60
+ await this.restoreStatus(statusPath, signal);
61
+ throw error;
62
+ }
63
+ }
64
+ async readStatusForRollback(statusPath) {
65
+ try {
66
+ return await this.planReadFilesystem.readFile(statusPath, 'utf8');
67
+ }
68
+ catch (error) {
69
+ if (error.code === 'ENOENT')
70
+ return undefined;
71
+ throw error;
72
+ }
73
+ }
74
+ async restoreStatus(statusPath, signal) {
75
+ const candidate = join(dirname(statusPath), `.${randomUUID()}.status.tmp`);
76
+ try {
77
+ await writeFile(candidate, signal, { mode: 0o600 });
78
+ await rename(candidate, statusPath);
79
+ }
80
+ catch (error) {
81
+ await rm(candidate, { force: true }).catch(() => { });
82
+ throw error;
83
+ }
84
+ }
85
+ async dismiss(sessionId, identity) {
86
+ const path = this.dismissalPath(sessionId);
87
+ const dismissed = await this.dismissed(sessionId);
88
+ const next = new Set(dismissed).add(identity);
89
+ const candidate = join(this.dismissalDirectory, `.${randomUUID()}.dismissals.tmp`);
90
+ try {
91
+ await this.dismissalFilesystem.writeFile(candidate, JSON.stringify([...next]), { mode: 0o600 });
92
+ await this.dismissalFilesystem.rename(candidate, path);
93
+ }
94
+ catch (error) {
95
+ await rm(candidate, { force: true }).catch(() => { });
96
+ throw error;
97
+ }
98
+ this.dismissedBySession.set(sessionId, next);
99
+ }
100
+ async isDismissed(sessionId, identity) {
101
+ return (await this.dismissed(sessionId)).has(identity);
102
+ }
103
+ dismissedBySession = new Map();
104
+ async dismissed(sessionId) {
105
+ const cached = this.dismissedBySession.get(sessionId);
106
+ if (cached)
107
+ return cached;
108
+ try {
109
+ const values = JSON.parse(await this.planReadFilesystem.readFile(this.dismissalPath(sessionId), 'utf8'));
110
+ if (!Array.isArray(values) || values.some((value) => typeof value !== 'string'))
111
+ throw new Error();
112
+ const dismissed = new Set(values);
113
+ this.dismissedBySession.set(sessionId, dismissed);
114
+ return dismissed;
115
+ }
116
+ catch (error) {
117
+ if (error.code !== 'ENOENT')
118
+ throw error;
119
+ const absent = new Set();
120
+ this.dismissedBySession.set(sessionId, absent);
121
+ return absent;
122
+ }
123
+ }
124
+ dismissalPath(sessionId) {
125
+ return join(this.dismissalDirectory, `${createHash('sha256').update(sessionId).digest('hex')}.dismissals.json`);
126
+ }
127
+ }
128
+ export function planStatusDirectoryPath(workspacePath, sessionId) {
129
+ const opaqueSessionId = createHash('sha256').update(sessionId).digest('hex');
130
+ return join(workspacePath, '.gestalt', 'status', opaqueSessionId);
131
+ }
132
+ export function planStatusFilePath(statusDirectory, canonicalPlanPath) {
133
+ const opaquePlanId = createHash('sha256').update(canonicalPlanPath).digest('hex');
134
+ return join(statusDirectory, `${opaquePlanId}${statusFileSuffix}`);
135
+ }
136
+ class ActiveLease {
137
+ statusDirectory;
138
+ sessionId;
139
+ workspacePath;
140
+ listener;
141
+ planReadFilesystem;
142
+ isDismissed;
143
+ onActiveStatusPath;
144
+ onClose;
145
+ watcher;
146
+ debounce;
147
+ closed = false;
148
+ activeStatusPath;
149
+ constructor(statusDirectory, sessionId, workspacePath, listener, planReadFilesystem, isDismissed, onActiveStatusPath, onClose) {
150
+ this.statusDirectory = statusDirectory;
151
+ this.sessionId = sessionId;
152
+ this.workspacePath = workspacePath;
153
+ this.listener = listener;
154
+ this.planReadFilesystem = planReadFilesystem;
155
+ this.isDismissed = isDismissed;
156
+ this.onActiveStatusPath = onActiveStatusPath;
157
+ this.onClose = onClose;
158
+ }
159
+ async start() {
160
+ await this.refreshLatest();
161
+ try {
162
+ this.watcher = watch(this.statusDirectory, { persistent: false });
163
+ void this.consumeChanges();
164
+ }
165
+ catch {
166
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
167
+ }
168
+ }
169
+ close() {
170
+ if (this.closed)
171
+ return;
172
+ this.closed = true;
173
+ if (this.debounce)
174
+ clearTimeout(this.debounce);
175
+ void this.watcher?.return?.();
176
+ this.onClose();
177
+ }
178
+ async remove() {
179
+ if (this.activeStatusPath)
180
+ await rm(this.activeStatusPath, { force: true });
181
+ }
182
+ async consumeChanges() {
183
+ try {
184
+ for await (const event of this.watcher) {
185
+ if (this.closed)
186
+ return;
187
+ if (!event.filename) {
188
+ this.scheduleRefresh();
189
+ continue;
190
+ }
191
+ const filename = basename(String(event.filename));
192
+ if (!filename.endsWith(statusFileSuffix))
193
+ continue;
194
+ this.scheduleRefresh(join(this.statusDirectory, filename));
195
+ }
196
+ }
197
+ catch {
198
+ if (!this.closed)
199
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
200
+ }
201
+ }
202
+ pendingStatusPath;
203
+ scheduleRefresh(statusPath) {
204
+ this.pendingStatusPath = statusPath;
205
+ if (this.debounce)
206
+ clearTimeout(this.debounce);
207
+ this.debounce = setTimeout(() => {
208
+ this.debounce = undefined;
209
+ const pendingStatusPath = this.pendingStatusPath;
210
+ this.pendingStatusPath = undefined;
211
+ void (pendingStatusPath ? this.refresh(pendingStatusPath) : this.refreshLatest());
212
+ }, 25);
213
+ }
214
+ async refreshLatest() {
215
+ try {
216
+ const candidates = (await readdir(this.statusDirectory))
217
+ .filter((filename) => filename.endsWith(statusFileSuffix))
218
+ .map((filename) => join(this.statusDirectory, filename));
219
+ const signals = await Promise.all(candidates.map(async (statusPath) => {
220
+ const [source, metadata] = await Promise.all([
221
+ this.planReadFilesystem.readFile(statusPath, 'utf8').catch(() => ''),
222
+ stat(statusPath).catch(() => undefined),
223
+ ]);
224
+ return { statusPath, signal: parseSignal(source), modifiedAt: metadata?.mtimeMs ?? 0 };
225
+ }));
226
+ const latest = signals
227
+ .filter((candidate) => candidate.signal !== null)
228
+ .sort((left, right) => right.signal.updatedAt.localeCompare(left.signal.updatedAt) ||
229
+ right.modifiedAt - left.modifiedAt)[0];
230
+ if (latest)
231
+ await this.refresh(latest.statusPath, latest.signal);
232
+ }
233
+ catch {
234
+ if (!this.closed)
235
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
236
+ }
237
+ }
238
+ async refresh(statusPath, parsedSignal) {
239
+ try {
240
+ const signal = parsedSignal ?? parseSignal(await this.planReadFilesystem.readFile(statusPath, 'utf8'));
241
+ if (!signal)
242
+ throw new Error('INVALID_PLAN_STATUS');
243
+ const [planPath, workspacePath] = await Promise.all([
244
+ this.planReadFilesystem.realpath(signal.planPath),
245
+ this.planReadFilesystem.realpath(this.workspacePath),
246
+ ]);
247
+ if (!isPlanPathWithinWorkspace(planPath, workspacePath))
248
+ throw new Error('PATH_OUTSIDE_WORKSPACE');
249
+ const identity = createHash('sha256').update(planPath).digest('hex');
250
+ if (await this.isDismissed(identity))
251
+ return;
252
+ const result = parseSupervisedPlan({
253
+ source: await this.planReadFilesystem.readFile(planPath, 'utf8'),
254
+ planPath,
255
+ workspacePath,
256
+ });
257
+ if (result.kind === 'available') {
258
+ if (!this.closed && !(await this.isDismissed(identity)) && !this.closed) {
259
+ const previousStatusPath = this.activeStatusPath;
260
+ this.activeStatusPath = statusPath;
261
+ this.onActiveStatusPath(statusPath);
262
+ this.listener({ kind: 'updated', plan: result.plan, identity });
263
+ if (previousStatusPath && previousStatusPath !== statusPath)
264
+ await rm(previousStatusPath, { force: true }).catch(() => { });
265
+ }
266
+ }
267
+ else if (!this.closed) {
268
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
269
+ }
270
+ }
271
+ catch (error) {
272
+ if (error.code === 'ENOENT' &&
273
+ this.activeStatusPath !== undefined &&
274
+ statusPath !== this.activeStatusPath)
275
+ return;
276
+ if (!this.closed)
277
+ this.listener({ kind: 'unavailable', code: 'PLAN_STATUS_UNAVAILABLE' });
278
+ }
279
+ }
280
+ }
281
+ function parseSignal(source) {
282
+ try {
283
+ const value = JSON.parse(source);
284
+ if (!value || typeof value !== 'object')
285
+ return null;
286
+ const signal = value;
287
+ if (signal.schemaVersion !== 1 || typeof signal.planPath !== 'string')
288
+ return null;
289
+ if (typeof signal.reason !== 'string' || !isRfc3339Utc(signal.updatedAt))
290
+ return null;
291
+ return { planPath: signal.planPath, updatedAt: signal.updatedAt };
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ function isRfc3339Utc(value) {
298
+ return (typeof value === 'string' &&
299
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) &&
300
+ !Number.isNaN(Date.parse(value)));
301
+ }
@@ -0,0 +1,42 @@
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 { resolve } from 'node:path';
7
+ /** In-memory editor catalog: discovery runs only when the relay starts or a user refreshes it. */
8
+ export class CachedSkillCatalog {
9
+ discover;
10
+ entries = new Map();
11
+ constructor(discover) {
12
+ this.discover = discover;
13
+ }
14
+ async list(profile, workspace) {
15
+ return (this.entries.get(this.key(profile, workspace)) ?? {
16
+ skills: [],
17
+ errors: [
18
+ {
19
+ message: 'Skills are not cached for this workspace and Codex profile. Select Refresh skills to discover them.',
20
+ },
21
+ ],
22
+ });
23
+ }
24
+ async refresh(profile, workspace) {
25
+ try {
26
+ const result = await this.discover(profile, workspace);
27
+ this.entries.set(this.key(profile, workspace), result);
28
+ return result;
29
+ }
30
+ catch {
31
+ const result = {
32
+ skills: [],
33
+ errors: [{ message: 'Skill discovery failed. Select Refresh skills to try again.' }],
34
+ };
35
+ this.entries.set(this.key(profile, workspace), result);
36
+ return result;
37
+ }
38
+ }
39
+ key(profile, workspace) {
40
+ return `${profile}\u0000${resolve(workspace)}`;
41
+ }
42
+ }