gestalt-mobile 0.42.0 → 0.43.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 (40) hide show
  1. package/README.md +56 -0
  2. package/dist/client/assets/index-5-CmBBPG.css +1 -0
  3. package/dist/client/assets/index-BLgr7z1T.js +107 -0
  4. package/dist/client/index.html +2 -2
  5. package/dist/server/server/app.js +2 -3
  6. package/dist/server/server/composition.contracts.js +27 -0
  7. package/dist/server/server/composition.js +10 -2
  8. package/dist/server/server/features/agent-activity/registry.js +4 -0
  9. package/dist/server/server/features/autopilot/application/policy.js +42 -1
  10. package/dist/server/server/features/catalog/get-bootstrap/use-case.js +11 -0
  11. package/dist/server/server/features/catalog/register-routes.js +13 -0
  12. package/dist/server/server/features/catalog/session-defaults/endpoint.js +16 -0
  13. package/dist/server/server/features/files/check-references/endpoint.js +36 -0
  14. package/dist/server/server/features/files/read-entry/endpoint.js +28 -0
  15. package/dist/server/server/features/files/register-routes.js +4 -0
  16. package/dist/server/server/features/plans/application/parse-supervised-plan.js +11 -0
  17. package/dist/server/server/features/plans/archive-workspace-plan/endpoint.js +26 -0
  18. package/dist/server/server/features/plans/list-workspace-plans/endpoint.js +1 -1
  19. package/dist/server/server/features/plans/register-routes.js +6 -0
  20. package/dist/server/server/features/sessions/model/relay-session.js +2 -0
  21. package/dist/server/server/features/sessions/start-session/endpoint.js +6 -0
  22. package/dist/server/server/features/sessions/start-session/request.js +4 -0
  23. package/dist/server/server/features/sessions/start-session/use-case.js +12 -0
  24. package/dist/server/server/platform/catalog/filesystem-workspace-catalog.js +3 -0
  25. package/dist/server/server/platform/codex/recent-thread-lister.js +2 -1
  26. package/dist/server/server/platform/codex/session-model-config.js +104 -0
  27. package/dist/server/server/platform/codex/session-runtime.js +33 -13
  28. package/dist/server/server/platform/filesystem/filesystem-workspace-files.js +99 -0
  29. package/dist/server/server/platform/persistence/filesystem-session-defaults.js +42 -0
  30. package/dist/server/server/platform/persistence/migrate.js +2 -0
  31. package/dist/server/server/platform/persistence/sqlite-session-repository.js +7 -2
  32. package/dist/server/server/platform/plans/filesystem-plan-archiver.js +109 -0
  33. package/dist/server/server/platform/plans/filesystem-workspace-plan-catalog.js +96 -68
  34. package/dist/server/server/platform/plans/org-plan-archive-tag.js +17 -0
  35. package/dist/server/server/platform/plans/org-plan-discovery.js +53 -0
  36. package/dist/server/shared/contracts/session-defaults.js +20 -0
  37. package/dist/server/shared/contracts/session-model-settings.js +6 -0
  38. package/package.json +3 -1
  39. package/dist/client/assets/index-BRly-q56.js +0 -38
  40. package/dist/client/assets/index-D57mr3B1.css +0 -1
@@ -18,14 +18,22 @@ import { WriterAcquisitionError, } from '../../features/sessions/application/wri
18
18
  import { isCodexThreadWriterBusy, isMissingCodexThreadRollout } from './json-rpc-client.js';
19
19
  import { resolvedServerRequestId } from './server-request.js';
20
20
  /** Sticky thread config keeps skill paths out of the app-server process argv. */
21
- export function threadSkillConfig(skillsConfig) {
22
- return skillsConfig === undefined ? {} : { config: { skills: { config: skillsConfig } } };
21
+ export function threadSkillConfig(skillsConfig, modelConfig = {}) {
22
+ return skillsConfig === undefined && !Object.keys(modelConfig).length
23
+ ? {}
24
+ : {
25
+ config: {
26
+ ...modelConfig,
27
+ ...(skillsConfig === undefined ? {} : { skills: { config: skillsConfig } }),
28
+ },
29
+ };
23
30
  }
24
31
  /** One private owner for every resource acquired for a live Codex child. */
25
32
  class SessionResource {
26
33
  sessionId;
27
34
  process;
28
35
  skillsConfig;
36
+ modelConfig;
29
37
  planStatusLease;
30
38
  planMeasurementToken;
31
39
  unregister;
@@ -44,10 +52,11 @@ class SessionResource {
44
52
  completedProcessActions = new Map();
45
53
  turnThreads = new Map();
46
54
  childThreads = new Set();
47
- constructor(sessionId, process, skillsConfig, planStatusLease, planMeasurementToken, unregister, onDisposed) {
55
+ constructor(sessionId, process, skillsConfig, modelConfig, planStatusLease, planMeasurementToken, unregister, onDisposed) {
48
56
  this.sessionId = sessionId;
49
57
  this.process = process;
50
58
  this.skillsConfig = skillsConfig;
59
+ this.modelConfig = modelConfig;
51
60
  this.planStatusLease = planStatusLease;
52
61
  this.planMeasurementToken = planMeasurementToken;
53
62
  this.unregister = unregister;
@@ -92,13 +101,14 @@ export class CodexSessionRuntime {
92
101
  planMeasurementBaseUrl;
93
102
  maxPendingRequests;
94
103
  readerCwd;
104
+ resolveModelConfig;
95
105
  serverResponseListeners = new Set();
96
106
  constructor(launch,
97
107
  // Kept as an ignored compatibility slot while callers migrate from the old
98
108
  // correlated-map constructor shape. Live ownership is `sessions` only.
99
109
  _legacyProcesses = undefined, onNotification, onServerRequest, onProcessExit, resolveSkills, planStatusSource, onPlanStatus, planMeasurementBaseUrl,
100
110
  // Compatibility slot: blocking app-server requests wait for explicit input.
101
- _legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd) {
111
+ _legacyRequestTimeoutMs = undefined, maxPendingRequests = 64, readerCwd, resolveModelConfig) {
102
112
  this.launch = launch;
103
113
  this.onNotification = onNotification;
104
114
  this.onServerRequest = onServerRequest;
@@ -109,6 +119,7 @@ export class CodexSessionRuntime {
109
119
  this.planMeasurementBaseUrl = planMeasurementBaseUrl;
110
120
  this.maxPendingRequests = maxPendingRequests;
111
121
  this.readerCwd = readerCwd;
122
+ this.resolveModelConfig = resolveModelConfig;
112
123
  void _legacyProcesses;
113
124
  void _legacyRequestTimeoutMs;
114
125
  }
@@ -123,7 +134,7 @@ export class CodexSessionRuntime {
123
134
  clientInfo: { name: 'gestalt-mobile', version: '0.1.0' },
124
135
  capabilities: { experimentalApi: true },
125
136
  });
126
- const startedThreadId = await this.startThread(resource.process, session, resource.skillsConfig);
137
+ const startedThreadId = await this.startThread(resource.process, session, resource.skillsConfig, resource.modelConfig);
127
138
  resource.threadId = startedThreadId;
128
139
  this.sessions.set(session.id, resource);
129
140
  await this.writePendingThreadName(session.id);
@@ -224,6 +235,9 @@ export class CodexSessionRuntime {
224
235
  input: [{ type: 'text', text, text_elements: [] }],
225
236
  ...(clientUserMessageId ? { clientUserMessageId } : {}),
226
237
  ...(session.model ? { model: session.model } : {}),
238
+ ...(session.modelSettings?.reasoningEffort
239
+ ? { effort: session.modelSettings.reasoningEffort }
240
+ : {}),
227
241
  }));
228
242
  resource.turnThreads.set(result, session.threadId);
229
243
  return RelaySession.rehydrate(session).startTurn(result, now).snapshot;
@@ -236,7 +250,12 @@ export class CodexSessionRuntime {
236
250
  threadId: childThreadId,
237
251
  input: [{ type: 'text', text, text_elements: [] }],
238
252
  clientUserMessageId,
239
- ...(session.model ? { model: session.model } : {}),
253
+ ...(session.modelSettings?.executorModel
254
+ ? { model: session.modelSettings.executorModel }
255
+ : {}),
256
+ ...(session.modelSettings?.executorReasoningEffort
257
+ ? { effort: session.modelSettings.executorReasoningEffort }
258
+ : {}),
240
259
  }));
241
260
  if (resource.turnThreads.has(result) || resource.turnThreads.size < 256)
242
261
  resource.turnThreads.set(result, childThreadId);
@@ -677,7 +696,7 @@ export class CodexSessionRuntime {
677
696
  await resource.process.rpc.request('thread/resume', {
678
697
  threadId: session.threadId,
679
698
  cwd: session.workspacePath,
680
- ...threadSkillConfig(resource.skillsConfig),
699
+ ...threadSkillConfig(resource.skillsConfig, resource.modelConfig),
681
700
  ...(session.executionPolicy?.approvalPolicy
682
701
  ? { approvalPolicy: session.executionPolicy.approvalPolicy }
683
702
  : {}),
@@ -699,7 +718,7 @@ export class CodexSessionRuntime {
699
718
  catch (error) {
700
719
  if (!canRebindMissingRollout(session, error))
701
720
  throw error;
702
- const replacementThreadId = await this.startThread(resource.process, session, resource.skillsConfig);
721
+ const replacementThreadId = await this.startThread(resource.process, session, resource.skillsConfig, resource.modelConfig);
703
722
  result = rebindMissingRollout(session, error, replacementThreadId, now);
704
723
  }
705
724
  resource.threadId = result.session.threadId;
@@ -767,13 +786,13 @@ export class CodexSessionRuntime {
767
786
  reject(new Error('CODEX_SERVER_REQUEST_UNSUPPORTED'));
768
787
  });
769
788
  }
770
- async startThread(process, session, skillsConfig) {
771
- return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, skillsConfig)));
789
+ async startThread(process, session, skillsConfig, modelConfig = {}) {
790
+ return decodeThreadStart(await process.rpc.request('thread/start', this.threadStartParams(session, skillsConfig, modelConfig)));
772
791
  }
773
- threadStartParams(session, skillsConfig) {
792
+ threadStartParams(session, skillsConfig, modelConfig = {}) {
774
793
  return {
775
794
  cwd: session.workspacePath,
776
- ...threadSkillConfig(skillsConfig),
795
+ ...threadSkillConfig(skillsConfig, modelConfig),
777
796
  approvalPolicy: session.executionPolicy?.approvalPolicy ?? 'on-request',
778
797
  dynamicTools: [
779
798
  gestaltQuizDynamicTool,
@@ -801,6 +820,7 @@ export class CodexSessionRuntime {
801
820
  try {
802
821
  const token = randomUUID();
803
822
  const skillsConfig = await this.resolveSkills?.(session);
823
+ const modelConfig = (await this.resolveModelConfig?.(session)) ?? {};
804
824
  const process = this.launch({
805
825
  profile: session.profile,
806
826
  cwd: session.workspacePath,
@@ -820,7 +840,7 @@ export class CodexSessionRuntime {
820
840
  }
821
841
  : {}),
822
842
  });
823
- const resource = new SessionResource(session.id, process, skillsConfig, lease, token, [], () => {
843
+ const resource = new SessionResource(session.id, process, skillsConfig, modelConfig, lease, token, [], () => {
824
844
  if (this.sessions.get(session.id) === resource)
825
845
  this.sessions.delete(session.id);
826
846
  });
@@ -94,6 +94,105 @@ export class FilesystemWorkspaceFiles {
94
94
  : { kind: 'unreadable' };
95
95
  }
96
96
  }
97
+ async exists(rootPath, requestedPath) {
98
+ const root = await this.canonicalDirectory(rootPath);
99
+ if (!root)
100
+ return false;
101
+ const target = resolve(root, requestedPath);
102
+ if (!within(root, target))
103
+ return false;
104
+ const path = relative(root, target).split(sep).join('/');
105
+ if (path !== '' && !validRelative(path))
106
+ return false;
107
+ const parent = await anchoredDirectory(root, dirnameRelative(path));
108
+ if (!parent)
109
+ return false;
110
+ try {
111
+ if (!(await stableAnchor(root, dirnameRelative(path), parent.path)))
112
+ return false;
113
+ const handle = await open(path ? join(parent.path, basename(path)) : `${parent.path}/.`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
114
+ try {
115
+ const metadata = await handle.stat();
116
+ return ((metadata.isFile() || metadata.isDirectory()) &&
117
+ within(root, await realpath(procPath(handle.fd))));
118
+ }
119
+ finally {
120
+ await handle.close();
121
+ }
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ finally {
127
+ await parent.close();
128
+ }
129
+ }
130
+ async read(rootPath, requestedPath) {
131
+ const root = await this.canonicalDirectory(rootPath);
132
+ if (!root)
133
+ return { kind: 'unreadable' };
134
+ const target = resolve(root, requestedPath);
135
+ if (!within(root, target))
136
+ return { kind: 'unreadable' };
137
+ const path = relative(root, target).split(sep).join('/');
138
+ if (path !== '' && !validRelative(path))
139
+ return { kind: 'unreadable' };
140
+ const parentPath = dirnameRelative(path);
141
+ const parent = await anchoredDirectory(root, parentPath);
142
+ if (!parent)
143
+ return { kind: 'unreadable' };
144
+ try {
145
+ if (!(await stableAnchor(root, parentPath, parent.path)))
146
+ return { kind: 'unreadable' };
147
+ const handle = await open(path ? join(parent.path, basename(path)) : `${parent.path}/.`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
148
+ try {
149
+ const metadata = await handle.stat();
150
+ if (!within(root, await realpath(procPath(handle.fd))))
151
+ return { kind: 'unreadable' };
152
+ if (metadata.isDirectory())
153
+ return { kind: 'available', preview: { kind: 'directory', path } };
154
+ if (!metadata.isFile())
155
+ return { kind: 'unsupported' };
156
+ const limit = 1024 * 1024;
157
+ if (metadata.size > limit)
158
+ return { kind: 'too-large' };
159
+ const buffer = Buffer.alloc(limit + 1);
160
+ let size = 0;
161
+ while (size < buffer.length) {
162
+ const read = await handle.read(buffer, size, buffer.length - size, size);
163
+ if (!read.bytesRead)
164
+ break;
165
+ size += read.bytesRead;
166
+ }
167
+ if (size > limit)
168
+ return { kind: 'too-large' };
169
+ if (!(await stableAnchor(root, parentPath, parent.path)) ||
170
+ !within(root, await realpath(procPath(handle.fd))))
171
+ return { kind: 'unreadable' };
172
+ const bytes = buffer.subarray(0, size);
173
+ if (bytes.includes(0))
174
+ return { kind: 'unsupported' };
175
+ try {
176
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
177
+ return { kind: 'available', preview: { kind: 'file', path, content, size } };
178
+ }
179
+ catch {
180
+ return { kind: 'unsupported' };
181
+ }
182
+ }
183
+ finally {
184
+ await handle.close();
185
+ }
186
+ }
187
+ catch (error) {
188
+ return {
189
+ kind: error.code === 'ENOENT' ? 'missing' : 'unreadable',
190
+ };
191
+ }
192
+ finally {
193
+ await parent.close();
194
+ }
195
+ }
97
196
  async copy(rootPath, input) {
98
197
  return this.transfer(rootPath, input, false);
99
198
  }
@@ -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 { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { sessionDefaultsSchema, } from '../../../shared/contracts/session-defaults.js';
10
+ export class FilesystemSessionDefaults {
11
+ directory;
12
+ path;
13
+ constructor(homeDirectory) {
14
+ this.directory = join(homeDirectory, '.gestalt');
15
+ this.path = join(this.directory, 'session-defaults.json');
16
+ }
17
+ async read() {
18
+ try {
19
+ return sessionDefaultsSchema.parse(JSON.parse(await readFile(this.path, 'utf8')));
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'ENOENT')
23
+ return null;
24
+ throw error;
25
+ }
26
+ }
27
+ async save(defaults) {
28
+ const validated = sessionDefaultsSchema.parse(defaults);
29
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
30
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
31
+ try {
32
+ await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
33
+ mode: 0o600,
34
+ flag: 'wx',
35
+ });
36
+ await rename(temporary, this.path);
37
+ }
38
+ finally {
39
+ await rm(temporary, { force: true });
40
+ }
41
+ }
42
+ }
@@ -34,6 +34,8 @@ export function migrate(database) {
34
34
  database.exec('ALTER TABLE relay_sessions ADD COLUMN effective_skill_selection_json TEXT');
35
35
  if (!columns.some((column) => column.name === 'last_org_plan_json'))
36
36
  database.exec('ALTER TABLE relay_sessions ADD COLUMN last_org_plan_json TEXT');
37
+ if (!columns.some((column) => column.name === 'model_settings_json'))
38
+ database.exec('ALTER TABLE relay_sessions ADD COLUMN model_settings_json TEXT');
37
39
  if (!columns.some((column) => column.name === 'model'))
38
40
  database.exec('ALTER TABLE relay_sessions ADD COLUMN model TEXT');
39
41
  if (!columns.some((column) => column.name === 'branch'))
@@ -11,10 +11,10 @@ export class SqliteSessionRepository {
11
11
  }
12
12
  save(session) {
13
13
  this.db
14
- .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,provider,profile,model,branch,sandbox,approval_policy,thread_id,state,desired_state,active_turn_id,protocol_version,attention_tool_capability,failure_count,effective_skill_selection_json,last_org_plan_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,provider=excluded.provider,profile=excluded.profile,model=excluded.model,branch=excluded.branch,sandbox=excluded.sandbox,approval_policy=excluded.approval_policy,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,attention_tool_capability=excluded.attention_tool_capability,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,updated_at=excluded.updated_at')
14
+ .prepare('INSERT INTO relay_sessions (id,workspace_id,workspace_path,provider,profile,model,branch,sandbox,approval_policy,thread_id,state,desired_state,active_turn_id,protocol_version,attention_tool_capability,failure_count,effective_skill_selection_json,last_org_plan_json,model_settings_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET workspace_id=excluded.workspace_id,workspace_path=excluded.workspace_path,provider=excluded.provider,profile=excluded.profile,model=excluded.model,branch=excluded.branch,sandbox=excluded.sandbox,approval_policy=excluded.approval_policy,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,attention_tool_capability=excluded.attention_tool_capability,failure_count=excluded.failure_count,effective_skill_selection_json=excluded.effective_skill_selection_json,last_org_plan_json=excluded.last_org_plan_json,model_settings_json=excluded.model_settings_json,updated_at=excluded.updated_at')
15
15
  .run(session.id, session.workspaceId, session.workspacePath, session.provider, session.profile, session.model ?? null, session.branch ?? null, session.executionPolicy?.sandbox ?? null, session.executionPolicy?.approvalPolicy ?? null, session.threadId, session.state, session.desiredState, session.activeTurnId, session.protocolVersion, session.attentionToolCapability ?? null, session.failureCount, session.effectiveSkillSelection === undefined
16
16
  ? null
17
- : JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.createdAt, session.updatedAt);
17
+ : JSON.stringify(session.effectiveSkillSelection), session.lastOrgPlan === undefined ? null : JSON.stringify(session.lastOrgPlan), session.modelSettings === undefined ? null : JSON.stringify(session.modelSettings), session.createdAt, session.updatedAt);
18
18
  }
19
19
  find(id) {
20
20
  const row = this.db.prepare('SELECT * FROM relay_sessions WHERE id = ?').get(id);
@@ -48,6 +48,11 @@ function map(row) {
48
48
  provider: row.provider === 'kimi' ? 'kimi' : 'codex',
49
49
  profile: row.profile,
50
50
  ...(row.model === null ? {} : { model: row.model }),
51
+ ...(row.model_settings_json
52
+ ? {
53
+ modelSettings: JSON.parse(row.model_settings_json),
54
+ }
55
+ : {}),
51
56
  ...(row.branch === null ? {} : { branch: row.branch }),
52
57
  ...(executionPolicy === undefined ? {} : { executionPolicy }),
53
58
  threadId: row.thread_id,
@@ -0,0 +1,109 @@
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 { constants } from 'node:fs';
7
+ import { lstat, open, realpath, rename, rm, writeFile } from 'node:fs/promises';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ import { archiveOrgPlan } from './org-plan-archive-tag.js';
11
+ /** Adds a standard Org file tag without moving the plan or changing its tasks. */
12
+ export class FilesystemPlanArchiver {
13
+ beforePublish;
14
+ pending = new Map();
15
+ constructor(beforePublish = async () => { }) {
16
+ this.beforePublish = beforePublish;
17
+ }
18
+ async archive(workspacePath, planName) {
19
+ if (!isAbsolute(workspacePath) || !validPlanName(planName))
20
+ return { kind: 'missing' };
21
+ const key = `${workspacePath}\0${planName}`;
22
+ const active = this.pending.get(key);
23
+ if (active)
24
+ return active;
25
+ const request = this.write(workspacePath, planName).finally(() => this.pending.delete(key));
26
+ this.pending.set(key, request);
27
+ return request;
28
+ }
29
+ async write(workspacePath, planName) {
30
+ try {
31
+ const workspace = await realpath(workspacePath);
32
+ const path = resolve(workspace, ...planName.split('/'));
33
+ if ((await realpath(path)) !== path)
34
+ return { kind: 'unavailable' };
35
+ const parentPath = dirname(path);
36
+ const parent = await open(parentPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
37
+ try {
38
+ // Anchor publishing to the checked directory, even if a parent is renamed.
39
+ const anchor = `/proc/self/fd/${parent.fd}`;
40
+ if ((await realpath(anchor)) !== parentPath)
41
+ return { kind: 'unavailable' };
42
+ const target = join(anchor, basename(path));
43
+ const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
44
+ let metadata;
45
+ let source;
46
+ try {
47
+ metadata = await file.stat();
48
+ if (!metadata.isFile() || metadata.size > 1_048_576)
49
+ return { kind: 'unavailable' };
50
+ source = await file.readFile('utf8');
51
+ if (!sameFile(metadata, await file.stat()))
52
+ return { kind: 'conflict' };
53
+ }
54
+ finally {
55
+ await file.close();
56
+ }
57
+ const content = archiveOrgPlan(source);
58
+ if (content === source)
59
+ return { kind: 'archived' };
60
+ const temporary = join(anchor, `.${randomUUID()}.archive`);
61
+ try {
62
+ await writeFile(temporary, content, { flag: 'wx', mode: metadata.mode & 0o777 });
63
+ await this.beforePublish();
64
+ if ((await realpath(parentPath)) !== parentPath ||
65
+ (await realpath(anchor)) !== parentPath ||
66
+ !within(workspace, await realpath(anchor)))
67
+ return { kind: 'unavailable' };
68
+ if (!sameFile(metadata, await lstat(target)))
69
+ return { kind: 'conflict' };
70
+ await rename(temporary, target);
71
+ }
72
+ finally {
73
+ await rm(temporary, { force: true });
74
+ }
75
+ return { kind: 'archived' };
76
+ }
77
+ finally {
78
+ await parent.close();
79
+ }
80
+ }
81
+ catch (error) {
82
+ return {
83
+ kind: error.code === 'ENOENT' ? 'missing' : 'unavailable',
84
+ };
85
+ }
86
+ }
87
+ }
88
+ function sameFile(before, after) {
89
+ return (after.isFile() &&
90
+ !after.isSymbolicLink() &&
91
+ before.dev === after.dev &&
92
+ before.ino === after.ino &&
93
+ before.size === after.size &&
94
+ before.mtimeMs === after.mtimeMs &&
95
+ before.ctimeMs === after.ctimeMs);
96
+ }
97
+ function within(root, path) {
98
+ const name = relative(root, path);
99
+ return name === '' || (!isAbsolute(name) && name !== '..' && !name.startsWith(`..${sep}`));
100
+ }
101
+ function validPlanName(name) {
102
+ const parts = name.split('/');
103
+ return (name.endsWith('.org') &&
104
+ !isAbsolute(name) &&
105
+ !name.includes('\\') &&
106
+ !name.includes('\0') &&
107
+ parts.slice(0, -1).includes('.gestalt') &&
108
+ parts.every((part) => part !== '' && part !== '.' && part !== '..' && part !== '.git'));
109
+ }
@@ -3,44 +3,112 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
7
- import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ import { lstat, readFile, realpath, stat } from 'node:fs/promises';
7
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
8
8
  import { parseSupervisedPlan } from '../../features/plans/application/parse-supervised-plan.js';
9
+ import { OrgPlanDiscovery } from './org-plan-discovery.js';
10
+ import { isArchivedOrgPlan } from './org-plan-archive-tag.js';
9
11
  const maximumBytes = 1_048_576;
10
- /**
11
- * Passive access to every regular `.org` file below a workspace. Symlinks are
12
- * never followed. Supported supervised plans include a browser preview; other
13
- * Org files remain visible in the catalog without one.
14
- */
12
+ /** Cached `.gestalt` Org discovery, with bounded parallel metadata checks. */
15
13
  export class FilesystemWorkspacePlanCatalog {
16
14
  filesystem;
17
- constructor(filesystem = { lstat, readdir, readFile, realpath, stat }) {
15
+ discovery;
16
+ now;
17
+ caches = new Map();
18
+ constructor(filesystem = { lstat, readFile, realpath, stat }, discovery = new OrgPlanDiscovery(), now = Date.now) {
18
19
  this.filesystem = filesystem;
20
+ this.discovery = discovery;
21
+ this.now = now;
19
22
  }
20
- async list(workspacePath) {
23
+ async list(workspacePath, refresh = false) {
21
24
  const workspace = await this.workspace(workspacePath);
22
25
  if (!workspace)
23
26
  return [];
24
- const planNames = await this.discover(workspace);
25
- const entries = [];
26
- for (const planName of planNames) {
27
- const candidate = await this.readCandidate(workspace, planName);
28
- if (candidate.kind === 'missing')
29
- continue;
30
- if (candidate.kind === 'unavailable') {
31
- entries.push(toFallbackEntry(planName));
32
- continue;
33
- }
34
- const parsed = parseSupervisedPlan({
35
- source: candidate.source,
36
- planPath: candidate.canonicalPath,
37
- workspacePath: workspace,
38
- });
39
- entries.push(parsed.kind === 'available'
40
- ? toEntry(planName, parsed.plan)
41
- : toFallbackEntry(planName, candidate.source));
27
+ let cache = this.caches.get(workspace);
28
+ if (!cache) {
29
+ cache = { entries: null, files: new Map(), refreshedAt: 0, request: null };
30
+ this.caches.set(workspace, cache);
42
31
  }
43
- return entries;
32
+ if (cache.request)
33
+ return cache.request;
34
+ if (!refresh && cache.entries && this.now() - cache.refreshedAt < 1_000)
35
+ return cache.entries;
36
+ const current = cache;
37
+ current.request = this.refresh(workspace, current).finally(() => {
38
+ current.request = null;
39
+ });
40
+ return current.request;
41
+ }
42
+ async refresh(workspace, cache) {
43
+ const planNames = [...new Set(await this.discovery.list(workspace))]
44
+ .filter((name) => isPlanPath(name) && name.split('/').slice(0, -1).includes('.gestalt'))
45
+ .sort((left, right) => left.localeCompare(right));
46
+ const entries = new Array(planNames.length).fill(null);
47
+ const files = new Map();
48
+ let next = 0;
49
+ await Promise.all(Array.from({ length: Math.min(8, planNames.length) }, async () => {
50
+ while (next < planNames.length) {
51
+ const index = next++;
52
+ const name = planNames[index];
53
+ let signature;
54
+ try {
55
+ const metadata = await this.filesystem.lstat(resolve(workspace, ...name.split('/')));
56
+ if (!metadata.isFile() || metadata.isSymbolicLink())
57
+ continue;
58
+ signature = [
59
+ metadata.dev,
60
+ metadata.ino,
61
+ metadata.size,
62
+ metadata.mtimeMs,
63
+ metadata.ctimeMs,
64
+ ].join(':');
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ const previous = cache.files.get(name);
70
+ if (previous?.signature === signature) {
71
+ try {
72
+ const canonical = await this.filesystem.realpath(resolve(workspace, ...name.split('/')));
73
+ if (!isWithin(workspace, canonical) ||
74
+ toPlanName(relative(workspace, canonical)) !== name)
75
+ continue;
76
+ }
77
+ catch {
78
+ continue;
79
+ }
80
+ entries[index] = previous.entry;
81
+ files.set(name, previous);
82
+ continue;
83
+ }
84
+ const candidate = await this.readCandidate(workspace, name);
85
+ if (candidate.kind === 'missing')
86
+ continue;
87
+ let entry;
88
+ if (candidate.kind === 'unavailable') {
89
+ entry = toFallbackEntry(name);
90
+ }
91
+ else {
92
+ const parsed = parseSupervisedPlan({
93
+ source: candidate.source,
94
+ planPath: candidate.canonicalPath,
95
+ workspacePath: workspace,
96
+ });
97
+ entry =
98
+ parsed.kind === 'available'
99
+ ? toEntry(name, parsed.plan)
100
+ : toFallbackEntry(name, candidate.source);
101
+ if (isArchivedOrgPlan(candidate.source))
102
+ entry = { ...entry, archived: true };
103
+ files.set(name, { signature, entry });
104
+ }
105
+ entries[index] = entry;
106
+ }
107
+ }));
108
+ cache.files = files;
109
+ cache.entries = entries.filter((entry) => entry !== null);
110
+ cache.refreshedAt = this.now();
111
+ return cache.entries;
44
112
  }
45
113
  async read(workspacePath, planName) {
46
114
  if (!isPlanPath(planName))
@@ -62,46 +130,6 @@ export class FilesystemWorkspacePlanCatalog {
62
130
  return null;
63
131
  }
64
132
  }
65
- async discover(workspace) {
66
- const pending = [workspace];
67
- const visited = new Set();
68
- const plans = [];
69
- while (pending.length > 0) {
70
- const directory = pending.pop();
71
- try {
72
- const before = await this.filesystem.lstat(directory);
73
- if (!before.isDirectory() || before.isSymbolicLink())
74
- continue;
75
- const canonical = await this.filesystem.realpath(directory);
76
- if (!isWithin(workspace, canonical))
77
- continue;
78
- const metadata = await this.filesystem.stat(canonical);
79
- if (!metadata.isDirectory() || visited.has(canonical))
80
- continue;
81
- visited.add(canonical);
82
- const children = await this.filesystem.readdir(canonical, {
83
- encoding: 'utf8',
84
- withFileTypes: true,
85
- });
86
- for (const child of children) {
87
- if (child.isSymbolicLink())
88
- continue;
89
- const childPath = join(canonical, child.name);
90
- if (child.isDirectory())
91
- pending.push(childPath);
92
- else if (child.isFile() && isPlanFilename(child.name)) {
93
- const planName = toPlanName(relative(workspace, childPath));
94
- if (isPlanPath(planName))
95
- plans.push(planName);
96
- }
97
- }
98
- }
99
- catch {
100
- // A disappearing or unreadable branch does not hide plans elsewhere.
101
- }
102
- }
103
- return plans.sort((left, right) => left.localeCompare(right));
104
- }
105
133
  async readFromWorkspace(workspace, planName) {
106
134
  const candidate = await this.readCandidate(workspace, planName);
107
135
  if (candidate.kind !== 'readable')
@@ -0,0 +1,17 @@
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 isArchivedOrgPlan(source) {
7
+ return [...source.matchAll(/^#\+FILETAGS:[ \t]*(.*)$/gim)].some((match) => match[1].split(/[:\s]+/).some((tag) => tag.toUpperCase() === 'ARCHIVE'));
8
+ }
9
+ export function archiveOrgPlan(source) {
10
+ if (isArchivedOrgPlan(source))
11
+ return source;
12
+ const existing = /^(#\+FILETAGS:[ \t]*)([^\r\n]*)(\r?)$/im;
13
+ if (existing.test(source))
14
+ return source.replace(existing, (_line, prefix, tags, carriage) => `${prefix}${tags.trimEnd()}${tags.trimEnd().endsWith(':') ? '' : ':'}ARCHIVE:${carriage}`);
15
+ const newline = source.includes('\r\n') ? '\r\n' : '\n';
16
+ return `#+FILETAGS: :ARCHIVE:${newline}${source}`;
17
+ }