@neuralnomads/codenomad-dev 0.18.0-dev-20260716-40cee071 → 0.18.0-dev-20260720-4c50829d

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 (89) hide show
  1. package/dist/auth/manager.js +1 -1
  2. package/dist/filesystem/__tests__/search-cache.test.js +5 -5
  3. package/dist/filesystem/search-cache.js +1 -1
  4. package/dist/index.js +42 -0
  5. package/dist/index.test.js +40 -10
  6. package/dist/opencode-update/service.js +150 -0
  7. package/dist/opencode-update/service.test.js +107 -0
  8. package/dist/permissions/auto-accept-manager.js +165 -0
  9. package/dist/permissions/auto-accept-manager.test.js +208 -0
  10. package/dist/permissions/auto-accept-store.js +5 -2
  11. package/dist/permissions/opencode-yolo-metadata.js +81 -0
  12. package/dist/permissions/opencode-yolo-metadata.test.js +54 -0
  13. package/dist/server/__tests__/remote-proxy.test.js +64 -56
  14. package/dist/server/http-server.js +12 -15
  15. package/dist/server/remote-proxy.js +68 -98
  16. package/dist/server/routes/opencode-update.js +37 -0
  17. package/dist/server/routes/opencode-update.test.js +34 -0
  18. package/dist/server/routes/remote-servers.js +1 -1
  19. package/dist/server/routes/workspaces.test.js +38 -0
  20. package/dist/server/routes/worktrees.js +19 -0
  21. package/dist/server/routes/yolo.js +2 -1
  22. package/dist/server/tls.js +2 -2
  23. package/dist/settings/binaries.test.js +2 -7
  24. package/dist/shutdown.js +67 -59
  25. package/dist/shutdown.test.js +115 -78
  26. package/dist/ui/remote-ui.js +2 -2
  27. package/dist/usage/providers/api-key.js +3 -3
  28. package/dist/usage/providers/google.js +1 -1
  29. package/dist/usage/providers/oauth.js +1 -36
  30. package/dist/usage/providers/special.js +3 -3
  31. package/dist/usage/service.js +1 -1
  32. package/dist/usage/service.test.js +5 -3
  33. package/dist/usage/shared.js +4 -4
  34. package/dist/usage/shared.test.js +4 -4
  35. package/dist/workspaces/__tests__/spawn.test.js +2 -2
  36. package/dist/workspaces/__tests__/workspace-identity.test.js +94 -286
  37. package/dist/workspaces/binary-path.test.js +10 -0
  38. package/dist/workspaces/instance-client.js +4 -3
  39. package/dist/workspaces/launch-cleanup.test.js +21 -25
  40. package/dist/workspaces/manager.js +300 -351
  41. package/dist/workspaces/manager.test.js +209 -237
  42. package/dist/workspaces/process-identity.darwin.test.js +88 -0
  43. package/dist/workspaces/process-identity.js +172 -149
  44. package/dist/workspaces/process-identity.test.js +86 -140
  45. package/dist/workspaces/runtime.js +261 -373
  46. package/dist/workspaces/runtime.test.js +222 -547
  47. package/package.json +1 -1
  48. package/public/assets/{FilesTab-BjztpSdj.js → FilesTab-ChfE8-8N.js} +2 -2
  49. package/public/assets/{GitChangesTab-B9XraSoR.js → GitChangesTab-ntRtkxIF.js} +2 -2
  50. package/public/assets/{SplitFilePanel-Cu_5-Z-E.js → SplitFilePanel-Dav7VCqK.js} +1 -1
  51. package/public/assets/StatusTab-D3toOefp.js +1 -0
  52. package/public/assets/{align-justify-BKS9gyRk.js → align-justify-D91sAsLc.js} +1 -1
  53. package/public/assets/{bundle-full-CXGXXlzR.js → bundle-full-CWkY9-v4.js} +1 -1
  54. package/public/assets/{diff-viewer-CSNOYoEB.js → diff-viewer-Cn_41zpc.js} +1 -1
  55. package/public/assets/index-3dx-hhNQ.js +1 -0
  56. package/public/assets/index-BKwfZLzw.js +1 -0
  57. package/public/assets/index-C5IioEFT.js +1 -0
  58. package/public/assets/index-CZYc0VcU.js +2 -0
  59. package/public/assets/index-CdCoXYXP.js +1 -0
  60. package/public/assets/index-CwAVops2.js +1 -0
  61. package/public/assets/index-CzvCOwKc.js +1 -0
  62. package/public/assets/index-D-loOWPv.js +1 -0
  63. package/public/assets/{index-DVc4ls8c.js → index-DM1cpVNo.js} +1 -1
  64. package/public/assets/{index-sLe9hEfQ.js → index-DOprq6Gt.js} +1 -1
  65. package/public/assets/index-L7fX3W8k.css +1 -0
  66. package/public/assets/index-w2KdHEME.js +1 -0
  67. package/public/assets/{loading-CDmt9OJU.js → loading-BfoDcWcg.js} +1 -1
  68. package/public/assets/main-CaTCcck1.js +68 -0
  69. package/public/assets/{markdown-1ZLSxCFT.js → markdown-D33YI2g2.js} +3 -3
  70. package/public/assets/{monaco-viewer-D6JgH76W.js → monaco-viewer-DM35aobI.js} +4 -4
  71. package/public/assets/{tool-call-CyXb63LH.js → tool-call-tV85Jv2i.js} +3 -3
  72. package/public/assets/{unified-picker-DCCdSVwI.js → unified-picker-DSRT9GMh.js} +1 -1
  73. package/public/assets/{wrap-text-pNMdTIq3.js → wrap-text-Dmrn2DKo.js} +1 -1
  74. package/public/index.html +4 -4
  75. package/public/loading.html +4 -4
  76. package/public/sw.js +1 -1
  77. package/dist/workspaces/launch-cleanup.js +0 -132
  78. package/public/assets/StatusTab-D_IE79r-.js +0 -1
  79. package/public/assets/index-B3YjNCh4.js +0 -1
  80. package/public/assets/index-B3ZD2_SU.js +0 -1
  81. package/public/assets/index-Bh2ofaQo.js +0 -1
  82. package/public/assets/index-CM0c9xRZ.js +0 -1
  83. package/public/assets/index-CdDxouJl.js +0 -2
  84. package/public/assets/index-D06Y2cFq.js +0 -1
  85. package/public/assets/index-PmE51vPh.js +0 -1
  86. package/public/assets/index-Qj63IiSj.css +0 -1
  87. package/public/assets/index-VY_zwmsG.js +0 -1
  88. package/public/assets/index-qiiH7egV.js +0 -1
  89. package/public/assets/main-CTN6Wwz1.js +0 -68
@@ -13,6 +13,12 @@ export class AutoAcceptManager {
13
13
  this.pending = new Map();
14
14
  /** instanceId:permissionId -> failure count, to stop retrying stuck permissions */
15
15
  this.replyAttempts = new Map();
16
+ this.hydratedInstances = new Set();
17
+ this.hydration = new Map();
18
+ this.queuedEvents = new Map();
19
+ this.instanceGeneration = new Map();
20
+ this.sessionWorkspaces = new Map();
21
+ this.mutations = new Map();
16
22
  }
17
23
  start() {
18
24
  if (this.unsubscribe)
@@ -20,8 +26,25 @@ export class AutoAcceptManager {
20
26
  const handler = (payload) => {
21
27
  if (!payload || !payload.instanceId || !payload.event)
22
28
  return;
29
+ if (this.deps.persistence && !this.hydratedInstances.has(payload.instanceId)) {
30
+ const queued = this.queuedEvents.get(payload.instanceId) ?? [];
31
+ queued.push(payload.event);
32
+ this.queuedEvents.set(payload.instanceId, queued);
33
+ void this.hydrateInstance(payload.instanceId).catch((error) => {
34
+ this.deps.logger.warn({ instanceId: payload.instanceId, err: error }, "Failed to hydrate persisted Yolo state");
35
+ });
36
+ return;
37
+ }
23
38
  this.handleInstanceEvent(payload.instanceId, payload.event);
24
39
  };
40
+ const onStarted = (event) => {
41
+ const instanceId = event.workspace?.id;
42
+ if (!instanceId)
43
+ return;
44
+ void this.hydrateInstance(instanceId).catch((error) => {
45
+ this.deps.logger.warn({ instanceId, err: error }, "Failed to hydrate persisted Yolo state");
46
+ });
47
+ };
25
48
  const onStopped = (event) => {
26
49
  if (event?.workspaceId)
27
50
  this.clearInstance(event.workspaceId);
@@ -31,10 +54,12 @@ export class AutoAcceptManager {
31
54
  this.clearInstance(event.workspace.id);
32
55
  };
33
56
  this.deps.eventBus.on("instance.event", handler);
57
+ this.deps.eventBus.on("workspace.started", onStarted);
34
58
  this.deps.eventBus.on("workspace.stopped", onStopped);
35
59
  this.deps.eventBus.on("workspace.error", onError);
36
60
  this.unsubscribe = () => {
37
61
  this.deps.eventBus.off("instance.event", handler);
62
+ this.deps.eventBus.off("workspace.started", onStarted);
38
63
  this.deps.eventBus.off("workspace.stopped", onStopped);
39
64
  this.deps.eventBus.off("workspace.error", onError);
40
65
  };
@@ -46,7 +71,51 @@ export class AutoAcceptManager {
46
71
  isEnabled(instanceId, sessionId) {
47
72
  return this.store.isEnabled(instanceId, sessionId);
48
73
  }
74
+ hydrateInstance(instanceId) {
75
+ if (!this.deps.persistence || this.hydratedInstances.has(instanceId))
76
+ return Promise.resolve();
77
+ const existing = this.hydration.get(instanceId);
78
+ if (existing)
79
+ return existing;
80
+ const generation = this.instanceGeneration.get(instanceId) ?? 0;
81
+ let hydrated = false;
82
+ const pending = this.deps.persistence.loadSessions(instanceId).then((sessions) => {
83
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation)
84
+ return;
85
+ this.store.clearInstance(instanceId);
86
+ const workspaces = new Map();
87
+ for (const session of sessions) {
88
+ this.store.upsertSession(instanceId, session);
89
+ if (session.workspaceId)
90
+ workspaces.set(session.id, session.workspaceId);
91
+ }
92
+ this.sessionWorkspaces.set(instanceId, workspaces);
93
+ for (const session of sessions) {
94
+ if (!session.yoloEnabled || this.store.familyRoot(instanceId, session.id) !== session.id)
95
+ continue;
96
+ this.store.setEnabled(instanceId, session.id, true);
97
+ this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: session.id, enabled: true });
98
+ this.drainPending(instanceId, session.id);
99
+ }
100
+ this.hydratedInstances.add(instanceId);
101
+ hydrated = true;
102
+ }).finally(() => {
103
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation)
104
+ return;
105
+ this.hydration.delete(instanceId);
106
+ if (!hydrated)
107
+ return;
108
+ const queued = this.queuedEvents.get(instanceId) ?? [];
109
+ this.queuedEvents.delete(instanceId);
110
+ for (const event of queued)
111
+ this.handleInstanceEvent(instanceId, event);
112
+ });
113
+ this.hydration.set(instanceId, pending);
114
+ return pending;
115
+ }
49
116
  toggle(instanceId, sessionId) {
117
+ if (this.deps.persistence)
118
+ return this.togglePersisted(instanceId, sessionId);
50
119
  const enabled = this.store.toggle(instanceId, sessionId);
51
120
  this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId, enabled });
52
121
  if (enabled) {
@@ -54,7 +123,61 @@ export class AutoAcceptManager {
54
123
  }
55
124
  return enabled;
56
125
  }
126
+ async togglePersisted(instanceId, sessionId) {
127
+ await this.hydrateInstance(instanceId);
128
+ const generation = this.instanceGeneration.get(instanceId) ?? 0;
129
+ const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => {
130
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
131
+ return this.store.isEnabled(instanceId, sessionId);
132
+ }
133
+ const rootSessionId = this.store.familyRoot(instanceId, sessionId);
134
+ const traversedRootSessionIds = new Set([rootSessionId]);
135
+ const enabled = !this.store.isEnabled(instanceId, rootSessionId);
136
+ await this.deps.persistence.persist(instanceId, rootSessionId, enabled, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId));
137
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
138
+ return this.store.isEnabled(instanceId, rootSessionId);
139
+ }
140
+ let persistedRootSessionId = rootSessionId;
141
+ let currentRootSessionId = this.store.familyRoot(instanceId, sessionId);
142
+ while (currentRootSessionId !== persistedRootSessionId) {
143
+ traversedRootSessionIds.add(currentRootSessionId);
144
+ await this.deps.persistence.persist(instanceId, currentRootSessionId, enabled, this.sessionWorkspaces.get(instanceId)?.get(currentRootSessionId));
145
+ if (enabled) {
146
+ await this.deps.persistence.persist(instanceId, persistedRootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(persistedRootSessionId));
147
+ }
148
+ persistedRootSessionId = currentRootSessionId;
149
+ currentRootSessionId = this.store.familyRoot(instanceId, sessionId);
150
+ }
151
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation) {
152
+ return this.store.isEnabled(instanceId, currentRootSessionId);
153
+ }
154
+ if (enabled) {
155
+ this.store.setEnabled(instanceId, currentRootSessionId, true);
156
+ }
157
+ else {
158
+ for (const traversedRootSessionId of traversedRootSessionIds) {
159
+ this.store.setEnabled(instanceId, traversedRootSessionId, false);
160
+ }
161
+ }
162
+ this.deps.eventBus.publish({ type: "yolo.stateChanged", instanceId, sessionId: currentRootSessionId, enabled });
163
+ if (enabled)
164
+ this.drainPending(instanceId, currentRootSessionId);
165
+ return enabled;
166
+ });
167
+ const settled = mutation.finally(() => {
168
+ if (this.mutations.get(instanceId) === settled)
169
+ this.mutations.delete(instanceId);
170
+ });
171
+ this.mutations.set(instanceId, settled);
172
+ return settled;
173
+ }
57
174
  clearInstance(instanceId) {
175
+ this.instanceGeneration.set(instanceId, (this.instanceGeneration.get(instanceId) ?? 0) + 1);
176
+ this.hydratedInstances.delete(instanceId);
177
+ this.hydration.delete(instanceId);
178
+ this.queuedEvents.delete(instanceId);
179
+ this.sessionWorkspaces.delete(instanceId);
180
+ this.mutations.delete(instanceId);
58
181
  this.store.clearInstance(instanceId);
59
182
  this.pending.delete(instanceId);
60
183
  const prefix = `${instanceId}:`;
@@ -101,13 +224,55 @@ export class AutoAcceptManager {
101
224
  return;
102
225
  const parentId = session.parentID ?? session.parentId ?? null;
103
226
  const revert = session.revert ?? undefined;
227
+ const enabledBefore = this.store.enabledRoots(instanceId);
104
228
  this.store.upsertSession(instanceId, { id: session.id, parentId, revert });
229
+ if (typeof session.workspaceID === "string" && session.workspaceID) {
230
+ const workspaces = this.sessionWorkspaces.get(instanceId) ?? new Map();
231
+ workspaces.set(session.id, session.workspaceID);
232
+ this.sessionWorkspaces.set(instanceId, workspaces);
233
+ }
234
+ this.persistRootMigration(instanceId, enabledBefore, this.store.enabledRoots(instanceId));
105
235
  // Session ancestry may have changed (parent discovered, revert toggled).
106
236
  // Re-drain pending permissions whose family root may have migrated into
107
237
  // an enabled family — mirrors the old UI's drainAutoAcceptPermissions-
108
238
  // ForInstance trigger on session.updated (#497).
109
239
  this.drainPending(instanceId, session.id);
110
240
  }
241
+ persistRootMigration(instanceId, before, after) {
242
+ if (!this.deps.persistence)
243
+ return;
244
+ const removed = before.filter((id) => !after.includes(id));
245
+ const added = after.filter((id) => !before.includes(id));
246
+ if (removed.length === 0 && added.length === 0)
247
+ return;
248
+ const generation = this.instanceGeneration.get(instanceId) ?? 0;
249
+ const mutation = (this.mutations.get(instanceId) ?? Promise.resolve(false)).catch(() => false).then(async () => {
250
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation)
251
+ return false;
252
+ const enabledRoots = new Set(this.store.enabledRoots(instanceId));
253
+ for (const rootSessionId of added) {
254
+ if (!enabledRoots.has(rootSessionId))
255
+ continue;
256
+ await this.deps.persistence.persist(instanceId, rootSessionId, true, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId));
257
+ }
258
+ for (const rootSessionId of removed) {
259
+ if (enabledRoots.has(rootSessionId))
260
+ continue;
261
+ if ((this.instanceGeneration.get(instanceId) ?? 0) !== generation)
262
+ return false;
263
+ await this.deps.persistence.persist(instanceId, rootSessionId, false, this.sessionWorkspaces.get(instanceId)?.get(rootSessionId));
264
+ }
265
+ return false;
266
+ });
267
+ const settled = mutation.finally(() => {
268
+ if (this.mutations.get(instanceId) === settled)
269
+ this.mutations.delete(instanceId);
270
+ });
271
+ this.mutations.set(instanceId, settled);
272
+ void settled.catch((error) => {
273
+ this.deps.logger.warn({ instanceId, err: error }, "Failed to migrate persisted Yolo family root");
274
+ });
275
+ }
111
276
  handlePermissionRequest(instanceId, eventType, permission) {
112
277
  const request = permission;
113
278
  if (!request)
@@ -63,6 +63,214 @@ describe("AutoAcceptManager session tree", () => {
63
63
  manager.stop();
64
64
  });
65
65
  });
66
+ describe("AutoAcceptManager persistence", () => {
67
+ it("hydrates a persisted family root before processing queued permissions", async () => {
68
+ const bus = new EventBus(noopLogger);
69
+ const replier = makeRecordingReplier();
70
+ let release;
71
+ const gate = new Promise((resolve) => { release = resolve; });
72
+ const persistence = {
73
+ async loadSessions() {
74
+ await gate;
75
+ return [
76
+ { id: "root", parentId: null, yoloEnabled: true },
77
+ { id: "child", parentId: "root", yoloEnabled: false },
78
+ ];
79
+ },
80
+ async persist() { },
81
+ };
82
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence });
83
+ manager.start();
84
+ publishSession(bus, "inst", "session.updated", { id: "child", parentID: "root" });
85
+ publishInstanceEvent(bus, "inst", {
86
+ type: "permission.v2.asked",
87
+ properties: { id: "permission", sessionID: "child" },
88
+ });
89
+ await flushMicrotasks();
90
+ assert.equal(replier.calls.length, 0);
91
+ release();
92
+ await manager.hydrateInstance("inst");
93
+ await flushMicrotasks();
94
+ assert.equal(manager.isEnabled("inst", "child"), true);
95
+ assert.equal(replier.calls.length, 1);
96
+ manager.stop();
97
+ });
98
+ it("persists before enabling memory and emitting feedback", async () => {
99
+ const bus = new EventBus(noopLogger);
100
+ const changes = [];
101
+ bus.on("yolo.stateChanged", (event) => changes.push(event));
102
+ let release;
103
+ const gate = new Promise((resolve) => { release = resolve; });
104
+ const writes = [];
105
+ const persistence = {
106
+ async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }]; },
107
+ async persist(...args) { writes.push(args); await gate; },
108
+ };
109
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
110
+ const toggle = manager.toggle("inst", "root");
111
+ await flushMicrotasks();
112
+ assert.deepEqual(writes, [["inst", "root", true, undefined]]);
113
+ assert.equal(manager.isEnabled("inst", "root"), false);
114
+ assert.equal(changes.length, 0);
115
+ release();
116
+ assert.equal(await toggle, true);
117
+ assert.equal(manager.isEnabled("inst", "root"), true);
118
+ assert.equal(changes.length, 1);
119
+ });
120
+ it("serializes concurrent toggles", async () => {
121
+ const bus = new EventBus(noopLogger);
122
+ const writes = [];
123
+ const persistence = {
124
+ async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }]; },
125
+ async persist(_instanceId, _rootSessionId, enabled) { writes.push(enabled); },
126
+ };
127
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
128
+ assert.deepEqual(await Promise.all([manager.toggle("inst", "root"), manager.toggle("inst", "root")]), [true, false]);
129
+ assert.deepEqual(writes, [true, false]);
130
+ assert.equal(manager.isEnabled("inst", "root"), false);
131
+ });
132
+ it("keeps queued permissions until a failed hydration can retry", async () => {
133
+ const bus = new EventBus(noopLogger);
134
+ const replier = makeRecordingReplier();
135
+ let attempts = 0;
136
+ const persistence = {
137
+ async loadSessions() {
138
+ if (++attempts === 1)
139
+ throw new Error("temporary failure");
140
+ return [{ id: "root", parentId: null, yoloEnabled: true }];
141
+ },
142
+ async persist() { },
143
+ };
144
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier, persistence });
145
+ manager.start();
146
+ publishInstanceEvent(bus, "inst", {
147
+ type: "permission.v2.asked",
148
+ properties: { id: "permission", sessionID: "root" },
149
+ });
150
+ await flushMicrotasks();
151
+ assert.equal(replier.calls.length, 0);
152
+ await manager.hydrateInstance("inst");
153
+ await flushMicrotasks();
154
+ assert.equal(replier.calls.length, 1);
155
+ manager.stop();
156
+ });
157
+ it("does not re-enable memory when a persisted toggle finishes after cleanup", async () => {
158
+ const bus = new EventBus(noopLogger);
159
+ const changes = [];
160
+ bus.on("yolo.stateChanged", (event) => changes.push(event));
161
+ let release;
162
+ const gate = new Promise((resolve) => { release = resolve; });
163
+ let writes = 0;
164
+ const persistence = {
165
+ async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }]; },
166
+ async persist() { writes += 1; await gate; },
167
+ };
168
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
169
+ const toggle = manager.toggle("inst", "root");
170
+ const queued = manager.toggle("inst", "root");
171
+ await flushMicrotasks();
172
+ manager.clearInstance("inst");
173
+ release();
174
+ assert.equal(await toggle, false);
175
+ assert.equal(await queued, false);
176
+ assert.equal(writes, 1);
177
+ assert.equal(manager.isEnabled("inst", "root"), false);
178
+ assert.equal(changes.length, 0);
179
+ });
180
+ it("moves persisted Yolo state when late ancestry changes the family root", async () => {
181
+ const bus = new EventBus(noopLogger);
182
+ const writes = [];
183
+ const persistence = {
184
+ async loadSessions() {
185
+ return [
186
+ { id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
187
+ { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
188
+ ];
189
+ },
190
+ async persist(...args) { writes.push(args); },
191
+ };
192
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
193
+ manager.start();
194
+ await manager.hydrateInstance("inst");
195
+ publishSession(bus, "inst", "session.updated", { id: "child", parentID: "parent", workspaceID: "workspace" });
196
+ await flushMicrotasks();
197
+ assert.equal(manager.isEnabled("inst", "parent"), true);
198
+ assert.deepEqual(writes, [
199
+ ["inst", "parent", true, "workspace"],
200
+ ["inst", "child", false, "workspace"],
201
+ ]);
202
+ manager.stop();
203
+ });
204
+ it("does not re-enable a family when ancestry repeatedly changes during a disable", async () => {
205
+ const bus = new EventBus(noopLogger);
206
+ let releaseFirst;
207
+ let releaseSecond;
208
+ const firstGate = new Promise((resolve) => { releaseFirst = resolve; });
209
+ const secondGate = new Promise((resolve) => { releaseSecond = resolve; });
210
+ const writes = [];
211
+ const persistence = {
212
+ async loadSessions() {
213
+ return [
214
+ { id: "grandparent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
215
+ { id: "parent", parentId: null, workspaceId: "workspace", yoloEnabled: false },
216
+ { id: "child", parentId: null, workspaceId: "workspace", yoloEnabled: true },
217
+ ];
218
+ },
219
+ async persist(...args) {
220
+ writes.push(args);
221
+ if (writes.length === 1)
222
+ await firstGate;
223
+ if (writes.length === 2)
224
+ await secondGate;
225
+ },
226
+ };
227
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
228
+ manager.start();
229
+ await manager.hydrateInstance("inst");
230
+ const toggle = manager.toggle("inst", "child");
231
+ await flushMicrotasks();
232
+ publishSession(bus, "inst", "session.updated", { id: "child", parentID: "parent", workspaceID: "workspace" });
233
+ releaseFirst();
234
+ await flushMicrotasks();
235
+ publishSession(bus, "inst", "session.updated", { id: "child", parentID: "grandparent", workspaceID: "workspace" });
236
+ releaseSecond();
237
+ assert.equal(await toggle, false);
238
+ await flushMicrotasks();
239
+ assert.equal(manager.isEnabled("inst", "grandparent"), false);
240
+ assert.equal(writes.some(([, , enabled]) => enabled === true), false, JSON.stringify(writes));
241
+ manager.stop();
242
+ });
243
+ it("allows a queued toggle to continue after an earlier persistence failure", async () => {
244
+ const bus = new EventBus(noopLogger);
245
+ let attempts = 0;
246
+ const persistence = {
247
+ async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }]; },
248
+ async persist() { if (++attempts === 1)
249
+ throw new Error("write failed"); },
250
+ };
251
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
252
+ const first = manager.toggle("inst", "root");
253
+ const second = manager.toggle("inst", "root");
254
+ await assert.rejects(Promise.resolve(first), /write failed/);
255
+ assert.equal(await second, true);
256
+ assert.equal(manager.isEnabled("inst", "root"), true);
257
+ });
258
+ it("does not restore a late hydration after workspace cleanup", async () => {
259
+ const bus = new EventBus(noopLogger);
260
+ let release;
261
+ const gate = new Promise((resolve) => { release = resolve; });
262
+ const persistence = {
263
+ async loadSessions() { await gate; return [{ id: "root", parentId: null, yoloEnabled: true }]; },
264
+ async persist() { },
265
+ };
266
+ const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence });
267
+ const hydration = manager.hydrateInstance("inst");
268
+ manager.clearInstance("inst");
269
+ release();
270
+ await hydration;
271
+ assert.equal(manager.isEnabled("inst", "root"), false);
272
+ });
273
+ });
66
274
  describe("AutoAcceptManager permission interception", () => {
67
275
  it("auto-replies to a v2 permission on an enabled family", async () => {
68
276
  const bus = new EventBus(noopLogger);
@@ -8,8 +8,8 @@
8
8
  * - a session with a `revert` snapshot is treated as its own root (fork)
9
9
  * - enabling any session enables its whole family root and vice-versa
10
10
  *
11
- * No persistence: state is lost on server restart, matching the "no
12
- * persistence for now" milestone.
11
+ * This store remains in-memory; AutoAcceptManager hydrates and persists it
12
+ * through OpenCode session metadata.
13
13
  */
14
14
  /**
15
15
  * Resolve the family-root session id for `sessionId` by walking the parent
@@ -94,6 +94,9 @@ export class AutoAcceptStore {
94
94
  const tree = this.sessions.get(instanceId);
95
95
  return resolveFamilyRoot(sessionId, (id) => tree?.get(id));
96
96
  }
97
+ enabledRoots(instanceId) {
98
+ return [...(this.enabled.get(instanceId) ?? [])];
99
+ }
97
100
  /**
98
101
  * Re-resolves every enabled family root for an instance after the session
99
102
  * tree changes (new session, updated parent/revert). If a root now resolves
@@ -0,0 +1,81 @@
1
+ import { createInstanceClient } from "../workspaces/instance-client";
2
+ const CODENOMAD_METADATA_VERSION = 1;
3
+ const SESSION_LIST_LIMIT = 10_000;
4
+ function record(value) {
5
+ return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
6
+ }
7
+ export function hasPersistedYolo(sessionId, metadata) {
8
+ const codenomad = record(record(metadata).codenomad);
9
+ const yolo = record(codenomad.yolo);
10
+ return codenomad.version === CODENOMAD_METADATA_VERSION
11
+ && yolo.enabled === true
12
+ && yolo.rootSessionId === sessionId;
13
+ }
14
+ export function mergePersistedYolo(metadata, rootSessionId, enabled) {
15
+ const current = record(metadata);
16
+ const codenomad = record(current.codenomad);
17
+ return {
18
+ ...current,
19
+ codenomad: {
20
+ ...codenomad,
21
+ version: CODENOMAD_METADATA_VERSION,
22
+ yolo: { enabled, rootSessionId },
23
+ },
24
+ };
25
+ }
26
+ export function mergePersistedWorktreeSlug(metadata, worktreeSlug) {
27
+ const current = record(metadata);
28
+ const codenomad = record(current.codenomad);
29
+ return {
30
+ ...current,
31
+ codenomad: { ...codenomad, version: CODENOMAD_METADATA_VERSION, worktreeSlug },
32
+ };
33
+ }
34
+ export function createOpencodeYoloPersistence(workspaceManager, createClient = createInstanceClient) {
35
+ const writes = new Map();
36
+ const clientFor = (instanceId) => {
37
+ const client = createClient(workspaceManager, instanceId);
38
+ if (!client)
39
+ throw new Error(`Yolo: instance ${instanceId} has no open port`);
40
+ return client;
41
+ };
42
+ const updateMetadata = (instanceId, sessionId, workspaceId, update) => {
43
+ const writeKey = sessionId;
44
+ const write = (writes.get(writeKey) ?? Promise.resolve()).catch(() => undefined).then(async () => {
45
+ const client = clientFor(instanceId);
46
+ const scope = { sessionID: sessionId, ...(workspaceId ? { workspace: workspaceId } : {}) };
47
+ const { data: session } = await client.session.get(scope, { throwOnError: true });
48
+ const metadata = update(session.metadata);
49
+ const { data } = await client.session.update({ ...scope, metadata }, { throwOnError: true });
50
+ return record(data?.metadata ?? metadata);
51
+ });
52
+ const settled = write.finally(() => {
53
+ if (writes.get(writeKey) === settled)
54
+ writes.delete(writeKey);
55
+ });
56
+ writes.set(writeKey, settled);
57
+ return settled;
58
+ };
59
+ return {
60
+ async loadSessions(instanceId) {
61
+ const { data } = await clientFor(instanceId).session.list({ scope: "project", limit: SESSION_LIST_LIMIT }, { throwOnError: true });
62
+ return (data ?? []).map((session) => ({
63
+ id: session.id,
64
+ parentId: session.parentID ?? null,
65
+ revert: session.revert,
66
+ workspaceId: session.workspaceID,
67
+ yoloEnabled: hasPersistedYolo(session.id, session.metadata),
68
+ }));
69
+ },
70
+ persist(instanceId, rootSessionId, enabled, workspaceId) {
71
+ return updateMetadata(instanceId, rootSessionId, workspaceId, (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined);
72
+ },
73
+ async hasProjectSession(instanceId, sessionId) {
74
+ const { data } = await clientFor(instanceId).session.list({ scope: "project", limit: SESSION_LIST_LIMIT }, { throwOnError: true });
75
+ return (data ?? []).some((session) => session.id === sessionId);
76
+ },
77
+ setWorktreeSlug(instanceId, sessionId, worktreeSlug) {
78
+ return updateMetadata(instanceId, sessionId, undefined, (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug));
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,54 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createOpencodeYoloPersistence, hasPersistedYolo, mergePersistedYolo } from "./opencode-yolo-metadata";
4
+ describe("OpenCode Yolo metadata", () => {
5
+ it("preserves unrelated metadata while replacing Yolo state", () => {
6
+ assert.deepEqual(mergePersistedYolo({ thirdParty: { keep: true }, codenomad: { version: 1, worktreeSlug: "feature" } }, "root", true), {
7
+ thirdParty: { keep: true },
8
+ codenomad: { version: 1, worktreeSlug: "feature", yolo: { enabled: true, rootSessionId: "root" } },
9
+ });
10
+ });
11
+ it("accepts only a marker owned by its session", () => {
12
+ const metadata = mergePersistedYolo({}, "root", true);
13
+ assert.equal(hasPersistedYolo("root", metadata), true);
14
+ assert.equal(hasPersistedYolo("fork", metadata), false);
15
+ assert.equal(hasPersistedYolo("root", mergePersistedYolo({}, "root", false)), false);
16
+ });
17
+ it("uses the session workspace for metadata updates", async () => {
18
+ const calls = [];
19
+ const client = {
20
+ session: {
21
+ async list() { return { data: [{ id: "root", parentID: null, workspaceID: "workspace", metadata: {} }] }; },
22
+ async get(parameters) { calls.push(parameters); return { data: { metadata: {} } }; },
23
+ async update(parameters) { calls.push(parameters); return { data: {} }; },
24
+ },
25
+ };
26
+ const persistence = createOpencodeYoloPersistence({}, () => client);
27
+ const [session] = await persistence.loadSessions("instance");
28
+ await persistence.persist("instance", "root", true, session?.workspaceId);
29
+ assert.equal(session?.workspaceId, "workspace");
30
+ assert.equal(calls[0]?.workspace, "workspace");
31
+ assert.equal(calls[1]?.workspace, "workspace");
32
+ });
33
+ it("serializes Yolo and worktree metadata writes across instances", async () => {
34
+ let metadata = { thirdParty: true };
35
+ const client = {
36
+ session: {
37
+ async get() { return { data: { metadata } }; },
38
+ async update(parameters) {
39
+ metadata = parameters.metadata;
40
+ return { data: { metadata } };
41
+ },
42
+ },
43
+ };
44
+ const persistence = createOpencodeYoloPersistence({}, () => client);
45
+ await Promise.all([
46
+ persistence.persist("instance-a", "root", true),
47
+ persistence.setWorktreeSlug("instance-b", "root", "feature"),
48
+ ]);
49
+ assert.deepEqual(metadata, {
50
+ thirdParty: true,
51
+ codenomad: { version: 1, yolo: { enabled: true, rootSessionId: "root" }, worktreeSlug: "feature" },
52
+ });
53
+ });
54
+ });