agentlas 1.0.65 → 1.0.66

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 (163) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +6 -4
  3. package/bin/agentlas.cjs +341 -16
  4. package/engine/acp/server.cjs +27 -1
  5. package/engine/agentlas-api-agent.cjs +151 -120
  6. package/engine/agentlas-banner.cjs +6 -2
  7. package/engine/agentlas-capabilities.cjs +17 -10
  8. package/engine/agentlas-cloud-runtime.cjs +4 -5
  9. package/engine/agentlas-config.cjs +19 -54
  10. package/engine/agentlas-core-harness.cjs +59 -5
  11. package/engine/agentlas-desktop-loadout.cjs +17 -3
  12. package/engine/agentlas-evolution.cjs +75 -18
  13. package/engine/agentlas-experience-exchange.cjs +145 -14
  14. package/engine/agentlas-experience-intake.cjs +2 -2
  15. package/engine/agentlas-i18n.cjs +0 -2
  16. package/engine/agentlas-input.cjs +273 -20
  17. package/engine/agentlas-judgment.cjs +0 -0
  18. package/engine/agentlas-mcp-env.cjs +84 -23
  19. package/engine/agentlas-mcp-wrapper.cjs +36 -4
  20. package/engine/agentlas-memory-governance.cjs +2 -1
  21. package/engine/agentlas-memory-import.cjs +49 -12
  22. package/engine/agentlas-native-host.cjs +279 -44
  23. package/engine/agentlas-onboard.cjs +23 -1
  24. package/engine/agentlas-permissions.cjs +5 -1
  25. package/engine/agentlas-sqlite-policy.cjs +40 -2
  26. package/engine/agentlas-tasks.cjs +7 -0
  27. package/engine/agentlas-tools.cjs +37 -10
  28. package/engine/agentlas-ui.cjs +26 -4
  29. package/engine/agentlas-workforce.cjs +29 -5
  30. package/engine/agentlas-workload-routing.cjs +83 -10
  31. package/engine/agentlas.cjs +10 -3
  32. package/engine/agents/builder.cjs +35 -10
  33. package/engine/agents/files.cjs +81 -10
  34. package/engine/agents/import-local.cjs +10 -2
  35. package/engine/agents/registry.cjs +10 -3
  36. package/engine/agents/router.cjs +7 -7
  37. package/engine/agents/routes.cjs +31 -9
  38. package/engine/automation/daemon.cjs +154 -40
  39. package/engine/automation/launchd.cjs +395 -16
  40. package/engine/automation/schedule.cjs +90 -3
  41. package/engine/automation/store.cjs +163 -59
  42. package/engine/browser/cdp.cjs +190 -17
  43. package/engine/browser/vault.cjs +1 -0
  44. package/engine/cli-output.cjs +80 -28
  45. package/engine/cloud/auth.cjs +131 -13
  46. package/engine/cloud/hub-client.cjs +41 -16
  47. package/engine/cloud-assets/cas.cjs +18 -9
  48. package/engine/cloud-assets/commands.cjs +108 -30
  49. package/engine/cloud-assets/package.cjs +66 -3
  50. package/engine/cloud-assets/restore.cjs +12 -9
  51. package/engine/cloud-assets/state.cjs +308 -113
  52. package/engine/commands/acp.cjs +13 -2
  53. package/engine/commands/automation.cjs +75 -24
  54. package/engine/commands/billing.cjs +6 -1
  55. package/engine/commands/browser.cjs +61 -29
  56. package/engine/commands/build.cjs +42 -12
  57. package/engine/commands/call.cjs +1 -1
  58. package/engine/commands/cd.cjs +1 -1
  59. package/engine/commands/cloud.cjs +2 -1
  60. package/engine/commands/connect.cjs +38 -13
  61. package/engine/commands/creds.cjs +74 -29
  62. package/engine/commands/doctor.cjs +17 -1
  63. package/engine/commands/document.cjs +38 -10
  64. package/engine/commands/env.cjs +6 -1
  65. package/engine/commands/firm.cjs +42 -12
  66. package/engine/commands/graph.cjs +36 -32
  67. package/engine/commands/help.cjs +11 -1
  68. package/engine/commands/hep.cjs +1 -1
  69. package/engine/commands/import.cjs +5 -4
  70. package/engine/commands/index.cjs +1 -1
  71. package/engine/commands/install.cjs +6 -4
  72. package/engine/commands/list.cjs +56 -16
  73. package/engine/commands/login.cjs +5 -0
  74. package/engine/commands/logout.cjs +6 -1
  75. package/engine/commands/mcp.cjs +8 -2
  76. package/engine/commands/memory.cjs +3 -2
  77. package/engine/commands/multimodal.cjs +12 -0
  78. package/engine/commands/native.cjs +5 -1
  79. package/engine/commands/one.cjs +78 -33
  80. package/engine/commands/plugin.cjs +13 -2
  81. package/engine/commands/project.cjs +15 -12
  82. package/engine/commands/research.cjs +18 -7
  83. package/engine/commands/roles.cjs +104 -28
  84. package/engine/commands/route.cjs +2 -2
  85. package/engine/commands/run.cjs +42 -9
  86. package/engine/commands/search.cjs +23 -2
  87. package/engine/commands/setup.cjs +6 -1
  88. package/engine/commands/storm.cjs +44 -6
  89. package/engine/commands/swarm.cjs +47 -9
  90. package/engine/commands/telegram.cjs +6 -1
  91. package/engine/commands/uninstall.cjs +12 -6
  92. package/engine/commands/update.cjs +5 -0
  93. package/engine/commands/upload.cjs +2 -1
  94. package/engine/commands/usage.cjs +6 -1
  95. package/engine/commands/version.cjs +6 -1
  96. package/engine/commands/whoami.cjs +6 -1
  97. package/engine/commands/workforce.cjs +60 -16
  98. package/engine/core/capability-grants.cjs +7 -2
  99. package/engine/core/daemon-client.cjs +36 -7
  100. package/engine/core/db.cjs +10 -2
  101. package/engine/core/desktop-core-fetch.cjs +477 -77
  102. package/engine/core/schema-ensure.cjs +23 -11
  103. package/engine/experience/build.cjs +5 -1
  104. package/engine/experience/intents.cjs +29 -82
  105. package/engine/experience/runtime.cjs +7 -1
  106. package/engine/experience/variant.cjs +58 -4
  107. package/engine/firms/orchestrate.cjs +9 -6
  108. package/engine/graph/ask-model.cjs +22 -11
  109. package/engine/graph/interview.cjs +74 -23
  110. package/engine/graph/package.cjs +63 -8
  111. package/engine/hephaestus/local-core.cjs +18 -5
  112. package/engine/hephaestus/runtime.cjs +48 -43
  113. package/engine/hub/install.cjs +230 -61
  114. package/engine/hub/plugins.cjs +391 -29
  115. package/engine/mcp/consent.cjs +25 -17
  116. package/engine/mcp/contract.cjs +229 -24
  117. package/engine/mcp/inventory.cjs +15 -5
  118. package/engine/mcp/plan.cjs +5 -1
  119. package/engine/mcp/probe.cjs +24 -9
  120. package/engine/memory-cli/curate.cjs +74 -16
  121. package/engine/oberon/common.cjs +37 -12
  122. package/engine/oberon/manifest.cjs +25 -2
  123. package/engine/oberon/outputs.cjs +49 -19
  124. package/engine/oberon/render.cjs +665 -40
  125. package/engine/project/career-graph.cjs +15 -2
  126. package/engine/project/controller.cjs +133 -15
  127. package/engine/project/credentials.cjs +212 -34
  128. package/engine/project/env-file.cjs +93 -8
  129. package/engine/project/memory-context.cjs +11 -57
  130. package/engine/project/ontology.cjs +66 -25
  131. package/engine/project/paths.cjs +42 -8
  132. package/engine/project/seed.cjs +88 -51
  133. package/engine/project/state.cjs +41 -16
  134. package/engine/project/team.cjs +26 -1
  135. package/engine/runtime-refusal.cjs +4 -1
  136. package/engine/runtimes/acp-driver.cjs +56 -9
  137. package/engine/runtimes/auth-evidence.cjs +21 -2
  138. package/engine/runtimes/detect.cjs +28 -8
  139. package/engine/runtimes/kinds.cjs +12 -4
  140. package/engine/runtimes/overrides.cjs +4 -3
  141. package/engine/runtimes/resolve.cjs +8 -7
  142. package/engine/runtimes/roles.cjs +7 -5
  143. package/engine/sessions/apply-fences.cjs +396 -56
  144. package/engine/sessions/fences.cjs +111 -27
  145. package/engine/sessions/memory-turn.cjs +62 -3
  146. package/engine/sessions/orchestrator.cjs +11 -1
  147. package/engine/sessions/prompt.cjs +66 -9
  148. package/engine/sessions/session.cjs +70 -10
  149. package/engine/sessions/sink.cjs +78 -12
  150. package/engine/sessions/store.cjs +29 -7
  151. package/engine/storm/storm.cjs +26 -3
  152. package/engine/storm/swarm.cjs +31 -3
  153. package/engine/telegram/connect.cjs +185 -22
  154. package/engine/ui/commands-catalog.cjs +1 -1
  155. package/engine/ui/palette.cjs +12 -2
  156. package/engine/ui/repl.cjs +57 -9
  157. package/engine/ui/screens.cjs +2 -4
  158. package/engine/vendor/desktop-core.manifest.json +5 -5
  159. package/engine/workforce/capture.cjs +9 -27
  160. package/engine/workforce/deps.cjs +18 -6
  161. package/engine/workforce/local-core-transport.cjs +68 -5
  162. package/package.json +1 -1
  163. package/engine/commands/career-graph.cjs +0 -51
@@ -30,6 +30,132 @@ const {
30
30
  } = require("../hub/install.cjs");
31
31
 
32
32
  const CLOUD_ASSET_STATE_FILE = "cloud-asset-state.v1.json";
33
+ const CLOUD_ASSET_STATE_MAX_BYTES = 1024 * 1024;
34
+ const CLOUD_ASSET_LOCK_STALE_MS = 30_000;
35
+ const CLOUD_ASSET_LOCK_WAIT_MS = 10_000;
36
+
37
+ function waitSync(milliseconds) {
38
+ const signal = new Int32Array(new SharedArrayBuffer(4));
39
+ Atomics.wait(signal, 0, 0, milliseconds);
40
+ }
41
+
42
+ function processIsAlive(pid) {
43
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
44
+ try {
45
+ process.kill(pid, 0);
46
+ return true;
47
+ } catch (error) {
48
+ if (error && error.code === "ESRCH") return false;
49
+ // EPERM means the process exists but belongs to another user. Unknown
50
+ // failures also stay fail-safe: never steal a lock from a possibly live owner.
51
+ return true;
52
+ }
53
+ }
54
+
55
+ function readLockOwner(lockPath) {
56
+ const ownerPath = path.join(lockPath, "owner.json");
57
+ let fd;
58
+ try {
59
+ fd = fs.openSync(ownerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
60
+ const before = fs.fstatSync(fd);
61
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > 512) {
62
+ throw new Error("lock owner is not a bounded private file");
63
+ }
64
+ const raw = fs.readFileSync(fd, "utf8");
65
+ const after = fs.fstatSync(fd);
66
+ if (
67
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
68
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
69
+ ) {
70
+ throw new Error("lock owner changed while it was read");
71
+ }
72
+ const parsed = JSON.parse(raw);
73
+ if (!Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || typeof parsed.nonce !== "string" || !/^[a-f0-9]{32}$/.test(parsed.nonce)) {
74
+ throw new Error("lock owner identity is invalid");
75
+ }
76
+ return parsed;
77
+ } catch (error) {
78
+ if (error && error.code === "ENOENT") return null;
79
+ throw error;
80
+ } finally {
81
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Directory rename is the lock hand-off boundary. A stale lock is moved to a
87
+ * unique quarantine path before cleanup, so a new owner's lock can never be
88
+ * removed by the old cleanup path.
89
+ */
90
+ function withCloudAssetLock(targetPath, label, action) {
91
+ const lockPath = `${targetPath}.lock`;
92
+ const lockParent = path.dirname(lockPath);
93
+ fs.mkdirSync(lockParent, { recursive: true, mode: 0o700 });
94
+ try { fs.chmodSync(lockParent, 0o700); } catch { /* best effort */ }
95
+ const deadline = Date.now() + CLOUD_ASSET_LOCK_WAIT_MS;
96
+ let acquired = false;
97
+ while (!acquired) {
98
+ try {
99
+ fs.mkdirSync(lockPath, { mode: 0o700 });
100
+ } catch (error) {
101
+ if (!error || (error.code !== "EEXIST" && error.code !== "ENOENT")) throw error;
102
+ if (error.code === "ENOENT") continue;
103
+ let stat;
104
+ try { stat = fs.lstatSync(lockPath); } catch (statError) {
105
+ if (statError && statError.code === "ENOENT") continue;
106
+ throw statError;
107
+ }
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} lock is unsafe`);
109
+ let owner = null;
110
+ try { owner = readLockOwner(lockPath); } catch { owner = null; }
111
+ if (Date.now() - stat.mtimeMs > CLOUD_ASSET_LOCK_STALE_MS && (!owner || !processIsAlive(owner.pid))) {
112
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
113
+ try {
114
+ fs.renameSync(lockPath, quarantine);
115
+ fs.rmSync(quarantine, { recursive: true, force: true });
116
+ continue;
117
+ } catch (reclaimError) {
118
+ if (reclaimError && reclaimError.code === "ENOENT") continue;
119
+ throw reclaimError;
120
+ }
121
+ }
122
+ if (Date.now() >= deadline) throw new Error(`${label} is busy; retry after the active operation finishes`);
123
+ waitSync(25);
124
+ continue;
125
+ }
126
+ try {
127
+ const owner = {
128
+ pid: process.pid,
129
+ nonce: crypto.randomBytes(16).toString("hex"),
130
+ createdAt: new Date().toISOString(),
131
+ };
132
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner) + "\n", {
133
+ encoding: "utf8",
134
+ mode: 0o600,
135
+ flag: "wx",
136
+ });
137
+ acquired = true;
138
+ } catch (error) {
139
+ const abandoned = `${lockPath}.abandoned-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
140
+ try {
141
+ fs.renameSync(lockPath, abandoned);
142
+ fs.rmSync(abandoned, { recursive: true, force: true });
143
+ } catch { /* original owner-write error remains authoritative */ }
144
+ throw error;
145
+ }
146
+ }
147
+ try {
148
+ return action();
149
+ } finally {
150
+ const cleanup = `${lockPath}.done-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
151
+ try {
152
+ fs.renameSync(lockPath, cleanup);
153
+ fs.rmSync(cleanup, { recursive: true, force: true });
154
+ } catch (error) {
155
+ if (!error || error.code !== "ENOENT") throw error;
156
+ }
157
+ }
158
+ }
33
159
 
34
160
  function normalizeCloudScopeFlag(value) {
35
161
  if (value === "owner-private" || value === "private" || value === "private-link") return "owner-private";
@@ -45,45 +171,68 @@ function cloudAssetStatePath() {
45
171
  return path.join(userDataDir(), CLOUD_ASSET_STATE_FILE);
46
172
  }
47
173
 
174
+ function normalizeCloudAssetState(parsed) {
175
+ if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
176
+ throw new Error("state schema is invalid");
177
+ }
178
+ const assets = {};
179
+ for (const [key, raw] of Object.entries(parsed.assets)) {
180
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
181
+ const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
182
+ if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
183
+ if (!Array.isArray(raw.sourceRoots)) throw new Error(`state entry ${key} sourceRoots is invalid`);
184
+ for (const item of raw.sourceRoots) {
185
+ if (typeof item !== "string" || !path.isAbsolute(item)) throw new Error(`state entry ${key} sourceRoots is invalid`);
186
+ }
187
+ const sourceRoots = [...new Set(raw.sourceRoots.map((item) => path.resolve(item)))].slice(0, 32);
188
+ assets[key] = { descriptor, sourceRoots };
189
+ }
190
+ if (!Array.isArray(parsed.deletedBases)) throw new Error("state deletedBases is invalid");
191
+ const deletedBases = parsed.deletedBases.map((item, index) => {
192
+ if (
193
+ !item || typeof item !== "object" || Array.isArray(item) ||
194
+ typeof item.rootPath !== "string" || !path.isAbsolute(item.rootPath) ||
195
+ typeof item.slug !== "string" || cloudSlug(item.slug) !== item.slug ||
196
+ !CLOUD_ASSET_SCOPES.has(item.scope) || !/^[A-Za-z0-9_-]{8,128}$/.test(String(item.cloudId || "")) ||
197
+ typeof item.revision !== "string" || !item.revision || item.revision.length > 512 || /["\\\u0000-\u001f\u007f]/.test(item.revision)
198
+ ) {
199
+ throw new Error(`state deletedBases[${index}] is invalid`);
200
+ }
201
+ return {
202
+ rootPath: path.resolve(item.rootPath),
203
+ slug: item.slug,
204
+ scope: item.scope,
205
+ cloudId: item.cloudId,
206
+ revision: item.revision,
207
+ };
208
+ }).slice(-256);
209
+ return { schemaVersion: 1, assets, deletedBases };
210
+ }
211
+
48
212
  function readCloudAssetState() {
49
213
  const statePath = cloudAssetStatePath();
50
- if (!fs.existsSync(statePath)) return { schemaVersion: 1, assets: {}, deletedBases: [] };
51
214
  let fd;
52
215
  try {
53
216
  // O_NOFOLLOW: 상태 파일이 심링크로 바꿔치기되면 읽지 않는다 (로컬 상태 위조 방어).
54
- fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
55
- const stat = fs.fstatSync(fd);
56
- if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("state file is not a bounded regular file");
57
- const parsed = JSON.parse(fs.readFileSync(fd, "utf8"));
58
- if (!parsed || parsed.schemaVersion !== 1 || !parsed.assets || typeof parsed.assets !== "object" || Array.isArray(parsed.assets)) {
59
- throw new Error("state schema is invalid");
217
+ try {
218
+ fd = fs.openSync(statePath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
219
+ } catch (error) {
220
+ if (error && error.code === "ENOENT") return { schemaVersion: 1, assets: {}, deletedBases: [] };
221
+ throw error;
60
222
  }
61
- const assets = {};
62
- for (const [key, raw] of Object.entries(parsed.assets)) {
63
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`state entry ${key} is invalid`);
64
- const descriptor = normalizeCloudAssetDescriptor(raw.descriptor, `state entry ${key}`);
65
- if (key !== cloudDescriptorKey(descriptor)) throw new Error(`state entry ${key} key is invalid`);
66
- const sourceRoots = Array.isArray(raw.sourceRoots)
67
- ? [...new Set(raw.sourceRoots.filter((item) => typeof item === "string" && path.isAbsolute(item)).map((item) => path.resolve(item)))].slice(0, 32)
68
- : [];
69
- assets[key] = { descriptor, sourceRoots };
223
+ const before = fs.fstatSync(fd);
224
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
225
+ throw new Error("state file is not a bounded private file");
70
226
  }
71
- const deletedBases = Array.isArray(parsed.deletedBases)
72
- ? parsed.deletedBases.filter((item) =>
73
- item && typeof item === "object" && !Array.isArray(item) &&
74
- typeof item.rootPath === "string" && path.isAbsolute(item.rootPath) &&
75
- typeof item.slug === "string" && cloudSlug(item.slug) === item.slug &&
76
- CLOUD_ASSET_SCOPES.has(item.scope) && typeof item.cloudId === "string" &&
77
- typeof item.revision === "string"
78
- ).map((item) => ({
79
- rootPath: path.resolve(item.rootPath),
80
- slug: item.slug,
81
- scope: item.scope,
82
- cloudId: item.cloudId,
83
- revision: item.revision,
84
- })).slice(-256)
85
- : [];
86
- return { schemaVersion: 1, assets, deletedBases };
227
+ const raw = fs.readFileSync(fd, "utf8");
228
+ const after = fs.fstatSync(fd);
229
+ if (
230
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
231
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
232
+ ) {
233
+ throw new Error("state file changed while it was read");
234
+ }
235
+ return normalizeCloudAssetState(JSON.parse(raw));
87
236
  } catch (error) {
88
237
  // 깨진 상태 파일을 빈 상태로 위장하면 stale 베이스로 원격 리비전을 덮어쓸 수 있다.
89
238
  throw new Error(`Agent Cloud local revision state is unreadable: ${error.message || error}`);
@@ -92,40 +241,62 @@ function readCloudAssetState() {
92
241
  }
93
242
  }
94
243
 
95
- function writeCloudAssetState(state) {
244
+ function writeCloudAssetStateUnlocked(state) {
96
245
  const statePath = cloudAssetStatePath();
97
- fs.mkdirSync(path.dirname(statePath), { recursive: true });
246
+ const normalized = normalizeCloudAssetState(state);
247
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
248
+ try { fs.chmodSync(path.dirname(statePath), 0o700); } catch { /* best effort */ }
98
249
  const temp = `${statePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
99
- const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
250
+ let fd;
100
251
  try {
101
- fs.writeFileSync(fd, JSON.stringify(state, null, 2) + "\n", "utf8");
102
- fs.fsyncSync(fd);
252
+ try {
253
+ fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
254
+ fs.writeFileSync(fd, JSON.stringify(normalized, null, 2) + "\n", "utf8");
255
+ fs.fsyncSync(fd);
256
+ } finally {
257
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
258
+ }
259
+ fs.renameSync(temp, statePath);
260
+ cloudApplyPortableFileMode(statePath, 0o600);
261
+ cloudFsyncDirectory(path.dirname(statePath));
103
262
  } finally {
104
- fs.closeSync(fd);
263
+ try { fs.rmSync(temp, { force: true }); } catch { /* best effort */ }
105
264
  }
106
- fs.renameSync(temp, statePath);
107
- cloudApplyPortableFileMode(statePath, 0o600);
108
- cloudFsyncDirectory(path.dirname(statePath));
265
+ return normalized;
266
+ }
267
+
268
+ function writeCloudAssetState(state) {
269
+ return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => writeCloudAssetStateUnlocked(state));
270
+ }
271
+
272
+ function updateCloudAssetState(mutator) {
273
+ if (typeof mutator !== "function") throw new TypeError("Cloud asset state mutator must be a function");
274
+ return withCloudAssetLock(cloudAssetStatePath(), "Agent Cloud local revision state", () => {
275
+ const state = readCloudAssetState();
276
+ const result = mutator(state);
277
+ writeCloudAssetStateUnlocked(state);
278
+ return result;
279
+ });
109
280
  }
110
281
 
111
282
  /** 서버가 준 리비전 영수증을 관측 상태로 승격. sourceRoot가 있으면 톰스톤도 해제한다. */
112
283
  function rememberCloudAssetDescriptor(value, options = {}) {
113
284
  const descriptor = normalizeCloudAssetDescriptor(value);
114
- const state = readCloudAssetState();
115
- const key = cloudDescriptorKey(descriptor);
116
- const previous = state.assets[key];
117
- const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
118
- const roots = sameRevision ? [...previous.sourceRoots] : [];
119
- if (options.sourceRoot) {
120
- const sourceRoot = path.resolve(options.sourceRoot);
121
- roots.push(sourceRoot);
122
- state.deletedBases = state.deletedBases.filter(
123
- (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
124
- );
125
- }
126
- state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
127
- writeCloudAssetState(state);
128
- return descriptor;
285
+ return updateCloudAssetState((state) => {
286
+ const key = cloudDescriptorKey(descriptor);
287
+ const previous = state.assets[key];
288
+ const sameRevision = previous && previous.descriptor.cloudId === descriptor.cloudId && previous.descriptor.revision === descriptor.revision;
289
+ const roots = sameRevision ? [...previous.sourceRoots] : [];
290
+ if (options.sourceRoot) {
291
+ const sourceRoot = path.resolve(options.sourceRoot);
292
+ roots.push(sourceRoot);
293
+ state.deletedBases = state.deletedBases.filter(
294
+ (item) => !(item.rootPath === sourceRoot && item.slug === descriptor.slug && item.scope === descriptor.scope),
295
+ );
296
+ }
297
+ state.assets[key] = { descriptor, sourceRoots: [...new Set(roots)].slice(0, 32) };
298
+ return descriptor;
299
+ });
129
300
  }
130
301
 
131
302
  function findCloudAssetDescriptor(slug, scope) {
@@ -193,18 +364,18 @@ function cloudUnboundDescriptorForSource(rootPath, slug, scope) {
193
364
 
194
365
  /** 이 소스 루트를 이미 관측된 클라우드 자산에 명시적으로 연결한다. */
195
366
  function bindCloudAssetSourceRoot(rootPath, slug, scope) {
196
- const state = readCloudAssetState();
197
- const key = `${scope}:${slug}`;
198
- const entry = state.assets[key];
199
- if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
200
- const normalizedRoot = path.resolve(rootPath);
201
- const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
202
- state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
203
- state.deletedBases = state.deletedBases.filter(
204
- (item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
205
- );
206
- writeCloudAssetState(state);
207
- return entry.descriptor;
367
+ return updateCloudAssetState((state) => {
368
+ const key = `${scope}:${slug}`;
369
+ const entry = state.assets[key];
370
+ if (!entry) throw new Error(`No observed Cloud revision for ${key} to bind.`);
371
+ const normalizedRoot = path.resolve(rootPath);
372
+ const roots = [...new Set([...entry.sourceRoots, normalizedRoot])].slice(0, 32);
373
+ state.assets[key] = { descriptor: entry.descriptor, sourceRoots: roots };
374
+ state.deletedBases = state.deletedBases.filter(
375
+ (item) => !(item.rootPath === normalizedRoot && item.slug === slug && item.scope === scope),
376
+ );
377
+ return entry.descriptor;
378
+ });
208
379
  }
209
380
 
210
381
  function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
@@ -221,64 +392,87 @@ function cloudBaseDescriptorForSource(marker, rootPath, slug, scope) {
221
392
  const stateDescriptor = entry && entry.sourceRoots.includes(normalizedRoot) ? entry.descriptor : null;
222
393
  if (!markerDescriptor) return stateDescriptor;
223
394
  if (!stateDescriptor) return markerDescriptor;
224
- return stateDescriptor.cloudId === markerDescriptor.cloudId && stateDescriptor.updatedAt >= markerDescriptor.updatedAt
395
+ return stateDescriptor.cloudId === markerDescriptor.cloudId && Date.parse(stateDescriptor.updatedAt) >= Date.parse(markerDescriptor.updatedAt)
225
396
  ? stateDescriptor
226
397
  : markerDescriptor;
227
398
  }
228
399
 
229
400
  function writeCloudSourceMarker(rootPath, scan, descriptor, options = {}) {
230
401
  const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
231
- if (fs.existsSync(markerPath)) {
232
- const stat = fs.lstatSync(markerPath);
233
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Agent Cloud revision marker is not a regular file");
234
- }
235
- const descriptors = cloudMarkerDescriptors(options.previousMarker);
236
- if (descriptor) descriptors[descriptor.scope] = descriptor;
237
- if (options.removeDescriptor) {
238
- const current = descriptors[options.removeDescriptor.scope];
239
- if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
240
- delete descriptors[options.removeDescriptor.scope];
402
+ const lockTarget = path.join(
403
+ userDataDir(),
404
+ "cloud-source-marker-locks",
405
+ crypto.createHash("sha256").update(path.resolve(rootPath)).digest("hex"),
406
+ );
407
+ return withCloudAssetLock(lockTarget, "Agent Cloud source marker", () => {
408
+ const previousMarker = readCloudSourceMarker(rootPath);
409
+ const descriptors = cloudMarkerDescriptors(previousMarker);
410
+ if (descriptor) descriptors[descriptor.scope] = descriptor;
411
+ if (options.removeDescriptor) {
412
+ const current = descriptors[options.removeDescriptor.scope];
413
+ if (current && current.cloudId === options.removeDescriptor.cloudId && current.revision === options.removeDescriptor.revision) {
414
+ delete descriptors[options.removeDescriptor.scope];
415
+ }
241
416
  }
242
- }
243
- const latest = descriptor || Object.values(descriptors)[0] || null;
244
- const marker = {
245
- schemaVersion: 1,
246
- source: "agentlas-cloud",
247
- slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
248
- packageHash: descriptor?.packageHash || options.packageHash || options.previousMarker?.packageHash || "",
249
- packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || options.previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
250
- fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (options.previousMarker?.fileCount || 0),
251
- totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (options.previousMarker?.totalBytes || 0),
252
- executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : options.previousMarker?.executablePaths,
253
- cloudAssets: descriptors,
254
- ...(latest ? latest : {}),
255
- restoredAt: options.previousMarker?.restoredAt,
256
- savedAt: new Date().toISOString(),
257
- };
258
- for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
259
- const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
260
- const fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
261
- try {
262
- fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
263
- fs.fsyncSync(fd);
264
- } finally {
265
- fs.closeSync(fd);
266
- }
267
- fs.renameSync(temp, markerPath);
268
- cloudApplyPortableFileMode(markerPath, 0o600);
269
- cloudFsyncDirectory(rootPath);
270
- return marker;
417
+ const latest = descriptor || Object.values(descriptors)[0] || null;
418
+ const marker = {
419
+ schemaVersion: 1,
420
+ source: "agentlas-cloud",
421
+ slug: latest?.slug || options.removeDescriptor?.slug || cloudSlug(path.basename(rootPath)),
422
+ packageHash: descriptor?.packageHash || options.packageHash || previousMarker?.packageHash || "",
423
+ packageHashVersion: descriptor?.packageHashVersion || options.packageHashVersion || previousMarker?.packageHashVersion || CLOUD_PACKAGE_HASH_V1,
424
+ fileCount: Number.isSafeInteger(options.fileCount) ? options.fileCount : (previousMarker?.fileCount || 0),
425
+ totalBytes: Number.isSafeInteger(options.totalBytes) ? options.totalBytes : (previousMarker?.totalBytes || 0),
426
+ executablePaths: Array.isArray(options.executablePaths) ? options.executablePaths : previousMarker?.executablePaths,
427
+ cloudAssets: descriptors,
428
+ ...(latest ? latest : {}),
429
+ restoredAt: previousMarker?.restoredAt,
430
+ savedAt: new Date().toISOString(),
431
+ };
432
+ for (const key of Object.keys(marker)) if (marker[key] === undefined) delete marker[key];
433
+ const temp = path.join(rootPath, `.${CLOUD_RESTORE_MARKER_PATH}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
434
+ let fd;
435
+ try {
436
+ try {
437
+ fd = fs.openSync(temp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
438
+ fs.writeFileSync(fd, JSON.stringify(marker, null, 2) + "\n", "utf8");
439
+ fs.fsyncSync(fd);
440
+ } finally {
441
+ if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best effort */ }
442
+ }
443
+ fs.renameSync(temp, markerPath);
444
+ cloudApplyPortableFileMode(markerPath, 0o600);
445
+ cloudFsyncDirectory(rootPath);
446
+ } finally {
447
+ try { fs.rmSync(temp, { force: true }); } catch { /* best effort */ }
448
+ }
449
+ return marker;
450
+ });
271
451
  }
272
452
 
273
453
  function readCloudSourceMarker(rootPath) {
274
454
  const markerPath = path.join(rootPath, CLOUD_RESTORE_MARKER_PATH);
275
- if (!fs.existsSync(markerPath)) return null;
276
455
  let fd;
277
456
  try {
278
- fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
279
- const stat = fs.fstatSync(fd);
280
- if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error("marker is not a bounded regular file");
281
- return JSON.parse(fs.readFileSync(fd, "utf8"));
457
+ try {
458
+ fd = fs.openSync(markerPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
459
+ } catch (error) {
460
+ if (error && error.code === "ENOENT") return null;
461
+ throw error;
462
+ }
463
+ const before = fs.fstatSync(fd);
464
+ if (!before.isFile() || before.nlink !== 1 || before.size <= 0 || before.size > CLOUD_ASSET_STATE_MAX_BYTES) {
465
+ throw new Error("marker is not a bounded private file");
466
+ }
467
+ const raw = fs.readFileSync(fd, "utf8");
468
+ const after = fs.fstatSync(fd);
469
+ if (
470
+ Buffer.byteLength(raw, "utf8") !== before.size || after.dev !== before.dev || after.ino !== before.ino ||
471
+ after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
472
+ ) {
473
+ throw new Error("marker changed while it was read");
474
+ }
475
+ return JSON.parse(raw);
282
476
  } finally {
283
477
  if (fd !== undefined) fs.closeSync(fd);
284
478
  }
@@ -291,6 +485,7 @@ module.exports = {
291
485
  cloudAssetStatePath,
292
486
  readCloudAssetState,
293
487
  writeCloudAssetState,
488
+ updateCloudAssetState,
294
489
  rememberCloudAssetDescriptor,
295
490
  findCloudAssetDescriptor,
296
491
  cloudMarkerDescriptors,
@@ -28,14 +28,25 @@ function descriptor() {
28
28
  }
29
29
 
30
30
  async function run(ctx, args) {
31
- if (args.includes("--info") || args.includes("--json")) {
31
+ const descriptorMode = ctx.output?.format === "json" || (args.length === 1 && ["--info", "--json"].includes(args[0]));
32
+ if (descriptorMode) {
33
+ if (args.some((arg) => !["--info", "--json"].includes(arg)) || new Set(args).size !== args.length) {
34
+ const error = new Error("Usage: agentlas acp [--info]");
35
+ error.code = "INVALID_ARGUMENT";
36
+ throw error;
37
+ }
32
38
  ctx.out(JSON.stringify(descriptor(), null, 2));
33
39
  return 0;
34
40
  }
35
- if (args.includes("--help") || args.includes("help")) {
41
+ if (args.length === 1 && ["--help", "-h", "help"].includes(args[0])) {
36
42
  ctx.out("Usage: agentlas acp [--info]\n Speak the Agent Client Protocol (v1) on stdio so an editor can run Agentlas as its agent.");
37
43
  return 0;
38
44
  }
45
+ if (args.length) {
46
+ const error = new Error("Usage: agentlas acp [--info]");
47
+ error.code = "INVALID_ARGUMENT";
48
+ throw error;
49
+ }
39
50
  // stdout is the protocol wire from here on: route everything human to stderr.
40
51
  const server = new AcpAgentServer({ ctx, input: process.stdin, output: process.stdout });
41
52
  await server.start();