agentlas 1.0.61 → 1.0.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 1.0.62 — 2026-08-29
4
+
5
+ - Terminal sessions now preserve ACP conversation identity and history across
6
+ editor turns, propagate cancellation and consented MCP configuration to
7
+ Cursor/Grok/Kimi, and reject overlapping prompts instead of cross-streaming
8
+ two JSON-RPC requests.
9
+ - Automation leases, Telegram pairing, cloud session storage, Local Core
10
+ transport, evolution writes, experience events, project path matching, and
11
+ memory ownership now fail closed under races and partial failures.
12
+ - The on-demand Desktop core cache is bound to the exact release SHA, includes
13
+ every built-in plugin runtime package, and is published as the audited
14
+ `desktop-core-v15` asset without dependency tests, fixtures, or local paths.
15
+ - npm packages exclude the generated Desktop core tree and tarball, shrinking
16
+ the install artifact from roughly 36 MB to under 1 MB while retaining the
17
+ checksummed v15 fetch manifest. The publication workflow enforces this
18
+ boundary.
19
+ - `agentlas update` now bounds registry connection and body parsing so a stalled
20
+ npm request cannot hang the CLI indefinitely.
4
21
 
5
22
  - `agentlas acp` — Agentlas as an Agent Client Protocol agent (Phase B-3). Zed,
6
23
  JetBrains IDEs and other ACP clients can run the project controller (or an
@@ -50,8 +50,20 @@ function textOfPrompt(blocks) {
50
50
  * events: { onDelta(text), onTool(name, summary, id), onToolResult(text, ok, id), onStatus(text) }
51
51
  * cancel(sessionKey) → void
52
52
  */
53
- function productionTurnRunner() {
54
- const sessions = new Map(); // acp sessionId → { session, agent, runtime }
53
+ function productionTurnRunner(options = {}) {
54
+ const sessions = new Map(); // currently running acp sessionId → Session
55
+ const createSession = options.createSession || ((ctx, spec, prompt) => {
56
+ const { Orchestrator } = require("../sessions/orchestrator.cjs");
57
+ const orch = new Orchestrator({ db: ctx.db(), lang: ctx.lang });
58
+ const session = orch.spawn({
59
+ agent: spec.agent,
60
+ runtime: spec.runtime,
61
+ permission: spec.permission,
62
+ cwd: spec.cwd,
63
+ title: prompt.slice(0, 60),
64
+ });
65
+ return { orch, session };
66
+ });
55
67
  return {
56
68
  async newSession(ctx, { cwd, runtimeKind, agentSlug }) {
57
69
  const { projectCwd } = require("../project/paths.cjs");
@@ -81,9 +93,17 @@ function productionTurnRunner() {
81
93
  return { agent, runtime, permission, cwd: workdir, project: resolved ? resolved.project : null };
82
94
  },
83
95
  async runTurn(ctx, spec, prompt, events) {
84
- const { Orchestrator } = require("../sessions/orchestrator.cjs");
85
- const orch = new Orchestrator({ db: ctx.db(), lang: ctx.lang });
86
- const session = orch.spawn({ agent: spec.agent, runtime: spec.runtime, permission: spec.permission, cwd: spec.cwd, title: prompt.slice(0, 60) });
96
+ // An ACP session is conversational. Recreating Terminal's Session here on
97
+ // every prompt gave each editor turn a new chat/runtime fingerprint even
98
+ // though the client kept sending the same ACP sessionId. Keep one Session
99
+ // on the session spec so DB history and provider resume state survive.
100
+ if (!spec.session) {
101
+ const created = await createSession(ctx, spec, prompt);
102
+ spec.orch = created && created.orch || null;
103
+ spec.session = created && created.session || created;
104
+ }
105
+ const session = spec.session;
106
+ if (!session || typeof session.send !== "function") throw new Error("ACP session runner failed to create a Terminal session");
87
107
  sessions.set(spec.acpSessionId, session);
88
108
  const listener = (ev) => {
89
109
  try {
@@ -104,7 +124,7 @@ function productionTurnRunner() {
104
124
  };
105
125
  } finally {
106
126
  session.removeListener("event", listener);
107
- sessions.delete(spec.acpSessionId);
127
+ if (sessions.get(spec.acpSessionId) === session) sessions.delete(spec.acpSessionId);
108
128
  }
109
129
  },
110
130
  cancel(acpSessionId) {
@@ -207,9 +227,15 @@ class AcpAgentServer {
207
227
  case "session/prompt": {
208
228
  const spec = this.sessions.get(String(params.sessionId));
209
229
  if (!spec) return this.error(id, -32602, "unknown sessionId");
230
+ // readline dispatches messages concurrently. Session.send() queues a
231
+ // second prompt and returns the first turn's promise, which would make
232
+ // both JSON-RPC requests stream each other's events and settle with the
233
+ // wrong result. ACP clients must wait for the current prompt response.
234
+ if (spec.promptInFlight) return this.error(id, -32001, "session prompt already in progress");
210
235
  const prompt = textOfPrompt(params.prompt).trim();
211
236
  if (!prompt) return this.reply(id, { stopReason: "end_turn" });
212
237
  const sid = spec.acpSessionId;
238
+ spec.promptInFlight = true;
213
239
  let streamed = false;
214
240
  const events = {
215
241
  onDelta: (text) => {
@@ -232,18 +258,22 @@ class AcpAgentServer {
232
258
  },
233
259
  onStatus: () => {},
234
260
  };
235
- const res = await this.runner.runTurn(this.ctx, spec, prompt, events);
236
- if (res && res.cancelled) return this.reply(id, { stopReason: "cancelled" });
237
- if (res && res.error) {
238
- const kind = String(res.errorKind || "");
239
- if (kind === "refused") return this.reply(id, { stopReason: "refusal" });
240
- if (kind === "auth") return this.error(id, -32000, `auth_required: ${res.error}`);
241
- // Non-marker failures: surface the runtime's own words as the answer, then end the turn.
242
- if (!streamed) events.onDelta(String(res.error));
243
- return this.reply(id, { stopReason: "end_turn", _meta: { agentlas: { error: String(res.error), errorKind: kind || null } } });
261
+ try {
262
+ const res = await this.runner.runTurn(this.ctx, spec, prompt, events);
263
+ if (res && res.cancelled) return this.reply(id, { stopReason: "cancelled" });
264
+ if (res && res.error) {
265
+ const kind = String(res.errorKind || "");
266
+ if (kind === "refused") return this.reply(id, { stopReason: "refusal" });
267
+ if (kind === "auth") return this.error(id, -32000, `auth_required: ${res.error}`);
268
+ // Non-marker failures: surface the runtime's own words as the answer, then end the turn.
269
+ if (!streamed) events.onDelta(String(res.error));
270
+ return this.reply(id, { stopReason: "end_turn", _meta: { agentlas: { error: String(res.error), errorKind: kind || null } } });
271
+ }
272
+ if (!streamed && res && res.text) events.onDelta(res.text);
273
+ return this.reply(id, { stopReason: "end_turn" });
274
+ } finally {
275
+ spec.promptInFlight = false;
244
276
  }
245
- if (!streamed && res && res.text) events.onDelta(res.text);
246
- return this.reply(id, { stopReason: "end_turn" });
247
277
  }
248
278
  case "session/cancel": {
249
279
  const spec = this.sessions.get(String(params.sessionId));
@@ -110,6 +110,37 @@ function currentTargetHash(file) {
110
110
  }
111
111
  }
112
112
 
113
+ function writeTargetAtomic(file, content) {
114
+ const dir = path.dirname(file);
115
+ fs.mkdirSync(dir, { recursive: true });
116
+ let mode = 0o600;
117
+ try {
118
+ const stat = fs.lstatSync(file);
119
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Evolution target must be a regular non-symbolic-link file");
120
+ mode = stat.mode & 0o777;
121
+ } catch (error) {
122
+ if (!error || error.code !== "ENOENT") throw error;
123
+ }
124
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
125
+ try {
126
+ fs.writeFileSync(temp, content, { encoding: "utf8", mode, flag: "wx" });
127
+ fs.renameSync(temp, file);
128
+ try { fs.chmodSync(file, mode); } catch { /* Windows/ACL-only host */ }
129
+ } catch (error) {
130
+ try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ function restoreTargetAfterFailure(file, before, expectedCurrentHash) {
136
+ const current = currentTargetHash(file);
137
+ if (current.hash !== expectedCurrentHash) {
138
+ throw new Error("Evolution persistence failed and the target changed again before rollback; manual repair is required");
139
+ }
140
+ if (before.exists) writeTargetAtomic(file, before.content);
141
+ else fs.rmSync(file, { force: true });
142
+ }
143
+
113
144
  function printCard(out, entry, index) {
114
145
  const { row, source } = entry;
115
146
  const card = source.humanCard && typeof source.humanCard === "object" ? source.humanCard : null;
@@ -205,29 +236,37 @@ function cmdApply(db, id, out, fail, agentFolder) {
205
236
  const agent = agentById(db, row.agent_id);
206
237
  if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
207
238
  const file = targetFilePath(agentFolder, agent, row.target_path);
208
- const current = currentTargetHash(file);
209
- if (current.hash !== row.before_hash) {
239
+ const before = currentTargetHash(file);
240
+ if (before.hash !== row.before_hash) {
210
241
  return fail("Agent prompt changed after this proposal was created; review it in the desktop app and re-propose.");
211
242
  }
212
243
  const now = new Date().toISOString();
213
- fs.mkdirSync(path.dirname(file), { recursive: true });
214
- fs.writeFileSync(file, row.after_content, "utf8");
215
- const verify = sha256(fs.readFileSync(file, "utf8"));
216
- if (verify !== row.after_hash) {
217
- // 원상복구 실패 노출(폴백 금지).
218
- fs.writeFileSync(file, row.before_content, "utf8");
219
- return fail("Applied content did not match the approved hash; restored the original.");
244
+ let wroteTarget = false;
245
+ try {
246
+ runWriteTransaction(db, () => {
247
+ const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
248
+ if (!locked || locked.status !== "candidate") throw new Error("Evolution proposal changed before apply; reload and review it again");
249
+ if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Agent prompt changed before apply; reload and review it again");
250
+ writeTargetAtomic(file, row.after_content);
251
+ wroteTarget = true;
252
+ if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Applied content did not match the approved hash");
253
+ db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
254
+ insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
255
+ const changed = db.prepare(
256
+ `UPDATE agent_evolution_proposals
257
+ SET status = 'applied', applied_at = COALESCE(applied_at, ?),
258
+ last_error = NULL, updated_at = ?
259
+ WHERE id = ? AND status = 'candidate'`,
260
+ ).run(now, now, row.id).changes;
261
+ if (changed !== 1) throw new Error("Evolution proposal changed before apply; no receipt was committed");
262
+ });
263
+ } catch (error) {
264
+ if (wroteTarget) {
265
+ try { restoreTargetAfterFailure(file, before, sha256(row.after_content)); }
266
+ catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
267
+ }
268
+ return fail(`${error.message}; the original target was restored.`);
220
269
  }
221
- runWriteTransaction(db, () => {
222
- db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
223
- insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
224
- db.prepare(
225
- `UPDATE agent_evolution_proposals
226
- SET status = 'applied', applied_at = COALESCE(applied_at, ?),
227
- last_error = NULL, updated_at = ?
228
- WHERE id = ? AND status = 'candidate'`,
229
- ).run(now, now, row.id);
230
- });
231
270
  out(`applied ${id} → ${row.target_path} (agent ${row.agent_id}). Revert with: agentlas evolve revert ${id}`);
232
271
  }
233
272
 
@@ -244,27 +283,37 @@ function cmdRevert(db, id, out, fail, agentFolder) {
244
283
  const agent = agentById(db, row.agent_id);
245
284
  if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
246
285
  const file = targetFilePath(agentFolder, agent, row.target_path);
247
- const current = currentTargetHash(file);
248
- if (current.hash !== row.after_hash) {
286
+ const before = currentTargetHash(file);
287
+ if (before.hash !== row.after_hash) {
249
288
  return fail("Agent prompt changed after this proposal was applied; revert blocked to avoid clobbering newer edits.");
250
289
  }
251
290
  const now = new Date().toISOString();
252
- fs.writeFileSync(file, row.before_content, "utf8");
253
- const verify = sha256(fs.readFileSync(file, "utf8"));
254
- if (verify !== row.before_hash) {
255
- fs.writeFileSync(file, row.after_content, "utf8");
256
- return fail("Reverted content did not match the original hash; restored the applied version.");
291
+ let wroteTarget = false;
292
+ try {
293
+ runWriteTransaction(db, () => {
294
+ const locked = db.prepare("SELECT status FROM agent_evolution_proposals WHERE id=?").get(row.id);
295
+ if (!locked || !["applied", "measured"].includes(locked.status)) throw new Error("Evolution proposal changed before revert; reload it first");
296
+ if (currentTargetHash(file).hash !== row.after_hash) throw new Error("Agent prompt changed before revert; reload it first");
297
+ writeTargetAtomic(file, row.before_content);
298
+ wroteTarget = true;
299
+ if (currentTargetHash(file).hash !== row.before_hash) throw new Error("Reverted content did not match the original hash");
300
+ db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
301
+ insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
302
+ const changed = db.prepare(
303
+ `UPDATE agent_evolution_proposals
304
+ SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
305
+ last_error = NULL, updated_at = ?
306
+ WHERE id = ? AND status IN ('applied','measured')`,
307
+ ).run(now, now, row.id).changes;
308
+ if (changed !== 1) throw new Error("Evolution proposal changed before revert; no receipt was committed");
309
+ });
310
+ } catch (error) {
311
+ if (wroteTarget) {
312
+ try { restoreTargetAfterFailure(file, before, sha256(row.before_content)); }
313
+ catch (rollbackError) { return fail(`${error.message}. ${rollbackError.message}`); }
314
+ }
315
+ return fail(`${error.message}; the applied target was restored.`);
257
316
  }
258
- runWriteTransaction(db, () => {
259
- db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
260
- insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
261
- db.prepare(
262
- `UPDATE agent_evolution_proposals
263
- SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
264
- last_error = NULL, updated_at = ?
265
- WHERE id = ? AND status IN ('applied','measured')`,
266
- ).run(now, now, row.id);
267
- });
268
317
  out(`reverted ${id} → restored ${row.target_path} (agent ${row.agent_id}).`);
269
318
  }
270
319
 
@@ -175,13 +175,24 @@ function tableExists(db, name) {
175
175
 
176
176
  function appendRunEvent(db, input) {
177
177
  if (!db || !tableExists(db, "run_events")) return false;
178
- const id = input.id;
179
- if (db.prepare("SELECT 1 FROM run_events WHERE id=?").get(id)) return false;
180
- const seqRow = db.prepare("SELECT COALESCE(MAX(seq), -1) + 1 AS seq FROM run_events WHERE run_id=?").get(input.runId);
181
- db.prepare(
182
- "INSERT OR IGNORE INTO run_events (id,run_id,seq,ts,kind,chat_id,automation_id,node_id,agent_id,payload_json) VALUES (?,?,?,?,?,NULL,NULL,NULL,?,?)",
183
- ).run(id, input.runId, Number(seqRow?.seq || 0), input.ts, input.kind, input.agentId || null, JSON.stringify(input.payload));
184
- return true;
178
+ // Allocate seq and insert in one SQLite statement. The old SELECT(MAX)+INSERT
179
+ // pair allowed two Terminal processes to choose the same (run_id, seq); one
180
+ // INSERT OR IGNORE then vanished while this function still reported success.
181
+ const inserted = db.prepare(
182
+ `INSERT OR IGNORE INTO run_events
183
+ (id,run_id,seq,ts,kind,chat_id,automation_id,node_id,agent_id,payload_json)
184
+ SELECT ?,?,COALESCE(MAX(seq) + 1, 0),?,?,NULL,NULL,NULL,?,?
185
+ FROM run_events WHERE run_id=?`,
186
+ ).run(
187
+ input.id,
188
+ input.runId,
189
+ input.ts,
190
+ input.kind,
191
+ input.agentId || null,
192
+ JSON.stringify(input.payload),
193
+ input.runId,
194
+ );
195
+ return Number(inserted?.changes || 0) === 1;
185
196
  }
186
197
 
187
198
  function persistRunReceipt(db, receipt, agentId) {
@@ -200,7 +211,9 @@ function persistRunReceipt(db, receipt, agentId) {
200
211
  function recordIntakeDecision(db, input) {
201
212
  const sourceHash = digestHex(INTAKE_POLICY_VERSION, input.agentId, input.memoryId || "none", input.exactBase?.agentReleaseId || "none", input.environmentKey || "none");
202
213
  appendRunEvent(db, {
203
- id: opaqueId("event", "experience-intake", sourceHash),
214
+ // The same curated memory can be observed by many distinct runs. Preserve
215
+ // retry idempotency within one run without collapsing later run evidence.
216
+ id: opaqueId("event", "experience-intake", input.runId, sourceHash),
204
217
  runId: input.runId,
205
218
  ts: input.ts,
206
219
  kind: input.kind || "experience-intake-decision",
@@ -213,8 +213,8 @@ function main() {
213
213
  * 에서 ls -la 실행 실증). 가장 가까운 명령을 제안하고 정직하게 멈춘다.
214
214
  * 진짜 한 단어 작업은 따옴표+run -p 로 그대로 실행된다.
215
215
  */
216
- if (normalized.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(normalized[0])) {
217
- const token = normalized[0];
216
+ if (commandArgv.length === 1 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(commandArgv[0])) {
217
+ const token = commandArgv[0];
218
218
  const names = Object.keys(commands.COMMANDS)
219
219
  .concat(Object.keys(commands.COMMAND_ALIASES || {}))
220
220
  .concat(commands.NOT_YET_PORTED || []);
@@ -228,7 +228,10 @@ function main() {
228
228
  : `See: agentlas help · to run it as a task: agentlas run -p "${token}"`);
229
229
  process.exit(1);
230
230
  }
231
- code = commands.COMMANDS.run().run(ctx, normalized);
231
+ // 전역 출력 플래그는 이미 parseOutputFlags 가 소비했다. 원래 normalized argv 를
232
+ // 다시 넘기면 `agentlas --json "do work"`가 모델에게 "--json do work"라고
233
+ // 지시하는 꼴이 된다. 실제 명령/작업 토큰만 실행 경로로 보낸다.
234
+ code = commands.COMMANDS.run().run(ctx, commandArgv);
232
235
  }
233
236
 
234
237
  Promise.resolve(code).then(
@@ -185,9 +185,30 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
185
185
  // 데스크탑 스케줄러는 60초마다 리스를 갱신한다. 여기는 한 번 잡고 끝이라
186
186
  // TTL(15분)을 넘긴 실행은 프로세스가 살아 있어도 회수 대상이 됐다 — 같은
187
187
  // 자동화가 두 실행기에서 겹쳐 도는 경로. 같은 주기로 심장박동을 보낸다.
188
+ let activeSession = null;
189
+ let leaseOwnershipLost = false;
190
+ let leaseRenewWarningEmitted = false;
188
191
  const leaseHeartbeat = setInterval(() => {
189
- try { store.renewAutomationLease(db, row.id); } catch { /* best-effort */ }
190
- }, 60_000);
192
+ try {
193
+ const renewed = store.renewAutomationLease(db, row.id);
194
+ if (!renewed) {
195
+ leaseOwnershipLost = true;
196
+ if (activeSession && typeof activeSession.kill === "function") activeSession.kill();
197
+ } else {
198
+ leaseRenewWarningEmitted = false;
199
+ }
200
+ } catch (error) {
201
+ // One SQLITE_BUSY/I/O miss is not ownership loss. Keep the run alive and
202
+ // retry on the next heartbeat, while surfacing the first deferred renewal.
203
+ if (!leaseRenewWarningEmitted) {
204
+ leaseRenewWarningEmitted = true;
205
+ const code = error && typeof error === "object" && "code" in error
206
+ ? String(error.code || "transient")
207
+ : "transient";
208
+ ctx.err(`automation lease heartbeat deferred (${code.slice(0, 80)})`);
209
+ }
210
+ }
211
+ }, Math.max(10, Number(opts.leaseHeartbeatMs) || 60_000));
191
212
  if (typeof leaseHeartbeat.unref === "function") leaseHeartbeat.unref();
192
213
 
193
214
  try {
@@ -244,9 +265,18 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
244
265
  timeoutConfig: opts.timeoutConfig,
245
266
  ...(markerChatId ? { chatId: markerChatId } : {}),
246
267
  });
268
+ activeSession = session;
269
+ if (leaseOwnershipLost) {
270
+ session.kill();
271
+ return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
272
+ }
247
273
  if (typeof opts.onSession === "function") opts.onSession(session);
248
274
 
249
275
  const res = await session.send(row.prompt_template);
276
+ if (leaseOwnershipLost) {
277
+ ctx.err("Automation execution lease ownership lost");
278
+ return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
279
+ }
250
280
  const finalText = (res && (res.finalText || res.text)) || "";
251
281
  const failed = session.status === "failed";
252
282
  const errMsg = failed ? String(session.lastError || "runtime turn failed").slice(0, 500) : null;
@@ -265,12 +295,15 @@ async function runAutomationOnce(ctx, db, row, opts = {}) {
265
295
  } catch (e) {
266
296
  const msg = String((e && e.message) || e).slice(0, 500);
267
297
  ctx.err(msg);
298
+ if (leaseOwnershipLost) {
299
+ return { ok: false, skipped: true, reason: "lease-lost", error: "Automation execution lease ownership lost" };
300
+ }
268
301
  store.recordRun(db, row.id, "error", msg, opts.scheduledFor);
269
302
  store.advanceAfterRun(db, row, { ok: false, advanceSchedule: !!opts.advanceSchedule });
270
303
  return { ok: false, error: msg };
271
304
  } finally {
272
305
  clearInterval(leaseHeartbeat);
273
- store.releaseAutomation(db, row.id);
306
+ store.releaseAutomation(db, row.id, store.LEASE_OWNER);
274
307
  }
275
308
  }
276
309
 
@@ -116,20 +116,22 @@ function claimAutomation(db, id, now = new Date(), owner = LEASE_OWNER) {
116
116
  */
117
117
  function renewAutomationLease(db, id, now = new Date(), owner = LEASE_OWNER) {
118
118
  if (!leaseSupported(db)) return false;
119
- try {
120
- const result = db
121
- .prepare("UPDATE automations SET claimed_at = ? WHERE id = ? AND lease_owner = ?")
122
- .run(now.toISOString(), id, owner);
123
- return (result.changes ?? result.rowsAffected ?? 0) > 0;
124
- } catch {
125
- return false;
126
- }
119
+ // false means definitive ownership loss. SQLite busy/I/O errors must throw
120
+ // so the caller can retry instead of killing a valid run as if a peer had
121
+ // taken the lease (Desktop renewAutomationRunLease contract).
122
+ const result = db
123
+ .prepare("UPDATE automations SET claimed_at = ? WHERE id = ? AND lease_owner = ? AND claimed_at IS NOT NULL")
124
+ .run(now.toISOString(), id, owner);
125
+ return (result.changes ?? result.rowsAffected ?? 0) > 0;
127
126
  }
128
127
 
129
- function releaseAutomation(db, id) {
128
+ function releaseAutomation(db, id, owner = LEASE_OWNER) {
130
129
  try {
131
- db.prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ?").run(id);
132
- } catch { /* best-effort */ }
130
+ const result = db
131
+ .prepare("UPDATE automations SET claimed_at = NULL, lease_owner = NULL WHERE id = ? AND lease_owner = ?")
132
+ .run(id, owner);
133
+ return (result.changes ?? result.rowsAffected ?? 0) > 0;
134
+ } catch { return false; }
133
135
  }
134
136
 
135
137
  /**
@@ -1,4 +1,4 @@
1
- -- Agentlas 첫 실행 부트스트랩 스키마 (생성: 2026-08-26T07:56:09Z)
1
+ -- Agentlas 첫 실행 부트스트랩 스키마 (생성: 2026-08-29T07:55:23Z)
2
2
  --
3
3
  -- ★생성물이다. 손으로 고치지 말고 재생성하라:
4
4
  -- node scripts/gen-bootstrap-schema.cjs
@@ -6,7 +6,7 @@
6
6
  -- 정본은 Desktop 의 마이그레이션 사다리(agentlas_desktop/electron/store/db.ts, SCHEMA_VERSION).
7
7
  -- 이 파일은 그 사다리를 **빈 DB** 에 끝까지 돌린 결과의 덤프이므로, 터미널이 만든 DB 는
8
8
  -- 처음부터 사다리 머리에 있다 — 데스크탑이 나중에 승급할 것이 남지 않는다.
9
- PRAGMA user_version=104;
9
+ PRAGMA user_version=105;
10
10
  CREATE TABLE active_runtime (
11
11
  id INTEGER PRIMARY KEY CHECK(id = 1),
12
12
  kind TEXT NOT NULL
@@ -867,6 +867,20 @@ CREATE TABLE installed_agents (
867
867
  installed_at TEXT NOT NULL,
868
868
  tone TEXT NOT NULL
869
869
  , env_requirements_json TEXT NOT NULL DEFAULT '[]', name_en TEXT NOT NULL DEFAULT '', tagline_en TEXT NOT NULL DEFAULT '', builtin INTEGER NOT NULL DEFAULT 0, role TEXT, visibility TEXT NOT NULL DEFAULT 'visible' CHECK(visibility IN ('visible','background','private')), entity_kind TEXT, local_display_name TEXT, bookmarked_at TEXT NULL, parent_team_id TEXT NULL);
870
+ CREATE TABLE invocation_steers (
871
+ id TEXT PRIMARY KEY,
872
+ chat_id TEXT NOT NULL,
873
+ original_run_id TEXT NOT NULL,
874
+ prompt_text TEXT NOT NULL,
875
+ prompt_hash TEXT NOT NULL,
876
+ request_json TEXT NOT NULL,
877
+ workspace_binding_json TEXT,
878
+ execution_context_json TEXT,
879
+ status TEXT NOT NULL CHECK(status IN ('queued','draining','started','cancelled','failed')),
880
+ drained_run_id TEXT,
881
+ queued_at TEXT NOT NULL,
882
+ updated_at TEXT NOT NULL
883
+ );
870
884
  CREATE TABLE judgment_verdicts (
871
885
  kind TEXT NOT NULL,
872
886
  signature TEXT NOT NULL,
@@ -1071,6 +1085,7 @@ CREATE TABLE one_org_members (
1071
1085
  pending_count INTEGER NOT NULL DEFAULT 0,
1072
1086
  pending_kind TEXT NOT NULL DEFAULT 'approval' CHECK(pending_kind IN ('approval','review','input')),
1073
1087
  unread_count INTEGER NOT NULL DEFAULT 0,
1088
+ unread_generation INTEGER NOT NULL DEFAULT 0,
1074
1089
  credit_state TEXT NOT NULL DEFAULT 'unknown' CHECK(credit_state IN ('ok','insufficient','unknown')),
1075
1090
  auto_select_tools INTEGER NOT NULL DEFAULT 1 CHECK(auto_select_tools IN (0,1)),
1076
1091
  collaboration_style TEXT NOT NULL DEFAULT 'default' CHECK(collaboration_style IN ('default','concise','warm','direct')),
@@ -1135,6 +1150,13 @@ CREATE TABLE projects (
1135
1150
  updated_at TEXT NOT NULL, folder_path TEXT, system_prompt TEXT, agent_pool_json TEXT NOT NULL DEFAULT '[]', source_type TEXT NOT NULL DEFAULT 'local', source_ref TEXT,
1136
1151
  FOREIGN KEY(default_agent_id) REFERENCES installed_agents(id) ON DELETE SET NULL
1137
1152
  );
1153
+ CREATE TABLE prompt_chat_start_intents (
1154
+ intent_id TEXT PRIMARY KEY,
1155
+ chat_id TEXT NOT NULL UNIQUE,
1156
+ prompt_digest TEXT NOT NULL,
1157
+ seed_only INTEGER NOT NULL CHECK(seed_only IN (0,1)),
1158
+ created_at TEXT NOT NULL
1159
+ );
1138
1160
  CREATE TABLE run_events (
1139
1161
  id TEXT PRIMARY KEY,
1140
1162
  run_id TEXT NOT NULL,
@@ -1426,6 +1448,10 @@ CREATE INDEX idx_installed_agent_hub_binding_exact
1426
1448
  ON installed_agent_hub_bindings(agent_definition_id, agent_release_id);
1427
1449
  CREATE INDEX idx_installed_agents_parent_team ON installed_agents(parent_team_id) WHERE parent_team_id IS NOT NULL;
1428
1450
  CREATE INDEX idx_installed_agents_visibility ON installed_agents(visibility, installed_at DESC);
1451
+ CREATE INDEX idx_invocation_steers_chat
1452
+ ON invocation_steers(chat_id, queued_at, id);
1453
+ CREATE INDEX idx_invocation_steers_queue
1454
+ ON invocation_steers(status, queued_at, id);
1429
1455
  CREATE INDEX idx_judgment_verdicts_recency ON judgment_verdicts(last_hit_at);
1430
1456
  CREATE INDEX idx_memory_agent ON memory_entries(agent_id, superseded_at);
1431
1457
  CREATE INDEX idx_memory_chat ON memory_entries(chat_id);
@@ -1467,6 +1493,8 @@ CREATE INDEX idx_plugin_builder_sessions_chat_updated
1467
1493
  ON plugin_builder_sessions(chat_id, updated_at DESC);
1468
1494
  CREATE INDEX idx_plugin_builder_sessions_slug_phase
1469
1495
  ON plugin_builder_sessions(slug, phase);
1496
+ CREATE INDEX idx_prompt_chat_start_chat
1497
+ ON prompt_chat_start_intents(chat_id);
1470
1498
  CREATE INDEX idx_run_events_agent_kind_ts
1471
1499
  ON run_events(agent_id, kind, ts DESC);
1472
1500
  CREATE INDEX idx_run_events_agent_ts ON run_events(agent_id, ts DESC);
@@ -24,6 +24,12 @@ const LOGIN_CALLBACK_PATH = "/callback";
24
24
  const LOGIN_TIMEOUT_MS = 180_000;
25
25
  const MAX_LOGIN_SESSION_BYTES = 16 * 1024;
26
26
 
27
+ function validLoginSessionValue(value) {
28
+ return typeof value === "string" && value.length > 0 &&
29
+ Buffer.byteLength(value, "utf8") <= MAX_LOGIN_SESSION_BYTES &&
30
+ !/[\u0000-\u0020\u007f;,]/.test(value);
31
+ }
32
+
27
33
  function webBaseUrl() {
28
34
  return (process.env.AGENTLAS_WEB_BASE_URL || "https://agentlas.cloud").replace(/\/$/, "");
29
35
  }
@@ -97,13 +103,13 @@ function createLoginCallbackGuard(expectedState) {
97
103
  message: "The callback did not include a session value.",
98
104
  };
99
105
  }
100
- if (Buffer.byteLength(value, "utf8") > MAX_LOGIN_SESSION_BYTES) {
106
+ if (!validLoginSessionValue(value)) {
101
107
  return {
102
108
  handled: true,
103
109
  final: true,
104
110
  ok: false,
105
111
  statusCode: 400,
106
- message: "The login session value is too large.",
112
+ message: "The login session value is invalid.",
107
113
  };
108
114
  }
109
115
  return { handled: true, final: true, ok: true, statusCode: 200, value, message: "Agentlas login complete" };
@@ -120,6 +126,9 @@ function openInBrowser(url) {
120
126
  : ["xdg-open", url];
121
127
  try {
122
128
  const child = spawn(argv[0], argv.slice(1), { stdio: "ignore", detached: true });
129
+ // spawn failures such as a missing xdg-open arrive asynchronously. Without
130
+ // a listener Node treats them as an uncaught process error.
131
+ child.once("error", () => {});
123
132
  child.unref();
124
133
  } catch { /* ignore */ }
125
134
  }
@@ -220,18 +229,35 @@ function cliSessionPath() {
220
229
 
221
230
  function readCliSessionValue() {
222
231
  try {
223
- const j = JSON.parse(fs.readFileSync(cliSessionPath(), "utf8"));
224
- return (j && typeof j.value === "string" && j.value) || null;
232
+ const p = cliSessionPath();
233
+ const stat = fs.lstatSync(p);
234
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_LOGIN_SESSION_BYTES * 2) return null;
235
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
236
+ return j && validLoginSessionValue(j.value) ? j.value : null;
225
237
  } catch {
226
238
  return null;
227
239
  }
228
240
  }
229
241
 
230
242
  function saveCliSession(value) {
243
+ if (!validLoginSessionValue(value)) throw new Error("Refusing to save an invalid Agentlas login session value.");
231
244
  const p = cliSessionPath();
232
245
  // 디렉터리 0700 + 파일 0600 — 기본 umask(0644)로 세션이 world-readable 이 되는 것을 막는다.
233
- fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
234
- fs.writeFileSync(p, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
246
+ const dir = path.dirname(p);
247
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
248
+ try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
249
+ const temp = path.join(dir, `.cli-session.${process.pid}.${crypto.randomUUID()}.tmp`);
250
+ try {
251
+ fs.writeFileSync(temp, JSON.stringify({ version: 1, value, updatedAt: new Date().toISOString() }, null, 2) + "\n", {
252
+ encoding: "utf8",
253
+ mode: 0o600,
254
+ flag: "wx",
255
+ });
256
+ fs.renameSync(temp, p);
257
+ } catch (error) {
258
+ try { fs.rmSync(temp, { force: true }); } catch { /* preserve original */ }
259
+ throw error;
260
+ }
235
261
  try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
236
262
  return p;
237
263
  }
@@ -246,7 +272,7 @@ function deleteCliSession() {
246
272
  // 쿠키 해석 순서 계약: AGENTLAS_SESSION env → 세션 파일. (v1의 keytar 폴백은
247
273
  // 데스크탑이 세션을 keytar에 두지 않아 항상 비어 있었다 — v2에서는 싣지 않는다.)
248
274
  function cloudSessionCookie() {
249
- if (process.env.AGENTLAS_SESSION) return `agentlas_session=${process.env.AGENTLAS_SESSION}`;
275
+ if (validLoginSessionValue(process.env.AGENTLAS_SESSION)) return `agentlas_session=${process.env.AGENTLAS_SESSION}`;
250
276
  const fileValue = readCliSessionValue();
251
277
  if (fileValue) return `agentlas_session=${fileValue}`;
252
278
  return null;
@@ -275,5 +301,5 @@ module.exports = {
275
301
  deleteCliSession,
276
302
  cloudSessionCookie,
277
303
  fetchSessionMeta,
278
- _test: { createLoginState, createLoginCallbackGuard },
304
+ _test: { createLoginState, createLoginCallbackGuard, validLoginSessionValue },
279
305
  };