@yuandc/aica 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +9 -0
  2. package/dist/acp/agent.js +54 -0
  3. package/dist/acp/client/acp-client.js +102 -0
  4. package/dist/acp/client/acp-content.js +13 -0
  5. package/dist/acp/client/acp-events.js +106 -0
  6. package/dist/acp/client/acp-process.js +34 -0
  7. package/dist/acp/client/acp-runtime-pool.js +248 -0
  8. package/dist/acp/client/context-usage.js +29 -0
  9. package/dist/acp/client/json-rpc.js +128 -0
  10. package/dist/acp/provider-types.js +1 -0
  11. package/dist/acp/providers/codex/codex-process.js +51 -0
  12. package/dist/acp/providers/codex/events.js +1473 -0
  13. package/dist/acp/providers/codex/permissions.js +49 -0
  14. package/dist/acp/providers/codex/provider.js +376 -0
  15. package/dist/acp/providers/codex-acp/adapter.js +947 -0
  16. package/dist/acp/providers/codex-acp/context-maintenance.js +148 -0
  17. package/dist/acp/providers/codex-acp/launch.js +35 -0
  18. package/dist/acp/providers/codex-acp/provider.js +486 -0
  19. package/dist/acp/providers/mimo/provider.js +448 -0
  20. package/dist/acp/providers/opencode/provider.js +489 -0
  21. package/dist/acp/providers/registry.js +23 -0
  22. package/dist/acp/standard-events.js +167 -0
  23. package/dist/commands/start.js +137 -0
  24. package/dist/commands/worker-auth.js +100 -0
  25. package/dist/commands/worker-project.js +57 -0
  26. package/dist/core/aca-config.js +74 -0
  27. package/dist/core/aca-server-client.js +57 -0
  28. package/dist/core/acp-event-coalescer.js +108 -0
  29. package/dist/core/acp-event-upload-filter.js +16 -0
  30. package/dist/core/acp-orphan-cleanup.js +91 -0
  31. package/dist/core/affected-files.js +268 -0
  32. package/dist/core/auth.js +36 -0
  33. package/dist/core/file-transfer-worker.js +169 -0
  34. package/dist/core/fs.js +28 -0
  35. package/dist/core/heartbeat.js +578 -0
  36. package/dist/core/job-permission-policy.js +42 -0
  37. package/dist/core/job-worker.js +749 -0
  38. package/dist/core/logger.js +42 -0
  39. package/dist/core/long-poll-worker.js +26 -0
  40. package/dist/core/machine-filesystem-worker.js +352 -0
  41. package/dist/core/paths.js +26 -0
  42. package/dist/core/process-identity.js +34 -0
  43. package/dist/core/process.js +33 -0
  44. package/dist/core/provider-health.js +54 -0
  45. package/dist/core/runtime-options.js +38 -0
  46. package/dist/core/worktree.js +95 -0
  47. package/dist/worker-cli.js +27 -0
  48. package/dist/worker-single-cli.js +17 -0
  49. package/package.json +35 -0
@@ -0,0 +1,749 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { compactAgentContext, sendAgentPrompt } from "../acp/agent.js";
5
+ import { acaServerRequest } from "./aca-server-client.js";
6
+ import { loadAcaConfig } from "./aca-config.js";
7
+ import { prepareLocalWorktree } from "./worktree.js";
8
+ import { getAcaHome } from "./paths.js";
9
+ import { collectAffectedFiles, extractAffectedFilePathsFromAcpRaw } from "./affected-files.js";
10
+ import { resolveJobPermissionPolicy } from "./job-permission-policy.js";
11
+ import { appendCoalescedAcpEvent } from "./acp-event-coalescer.js";
12
+ import { shouldUploadAcpEvent } from "./acp-event-upload-filter.js";
13
+ const CLAIM_INTERVAL_MS = 1_500;
14
+ const JOB_HEARTBEAT_INTERVAL_MS = 5_000;
15
+ const PERMISSION_POLL_INTERVAL_MS = 1_000;
16
+ const DEFAULT_ACP_EVENT_BATCH_INTERVAL_MS = 320;
17
+ const ACP_EVENT_BATCH_MAX_SIZE = 64;
18
+ const ACP_PRESENCE_MIN_INTERVAL_MS = 900;
19
+ const ACP_PRESENCE_KEEPALIVE_MS = 10_000;
20
+ const DEFAULT_MAX_CONCURRENT_JOBS = 0;
21
+ const WORKER_STOP_TIMEOUT_MS = 12_000;
22
+ function expandProjectRoot(value) {
23
+ if (!value)
24
+ return "";
25
+ if (value === "~")
26
+ return path.dirname(getAcaHome());
27
+ if (value.startsWith("~/"))
28
+ return path.join(path.dirname(getAcaHome()), value.slice(2));
29
+ return value;
30
+ }
31
+ function isChatProjectJob(job) {
32
+ const metadata = job.project_metadata;
33
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata) && metadata.kind === "chat")
34
+ return true;
35
+ return typeof job.project_root_path === "string" && (job.project_root_path.startsWith("~/.aca/chats/") ||
36
+ /(^|\/)\.aca\/chats\//.test(job.project_root_path));
37
+ }
38
+ export function startJobWorkerLoop(logger, dependencies = {}) {
39
+ let stopping = false;
40
+ let tickPromise = null;
41
+ let stopPromise = null;
42
+ const activeJobs = new Set();
43
+ const activeSessions = new Set();
44
+ const activeRuns = new Map();
45
+ const tick = async () => {
46
+ if (stopping)
47
+ return;
48
+ const maxConcurrentJobs = getMaxConcurrentJobs();
49
+ if (!hasJobCapacity(activeJobs.size, maxConcurrentJobs))
50
+ return;
51
+ try {
52
+ const config = dependencies.loadConfig?.() ?? loadAcaConfig();
53
+ if (!config.token)
54
+ return;
55
+ while (!stopping && hasJobCapacity(activeJobs.size, maxConcurrentJobs)) {
56
+ const response = dependencies.claimNextJob
57
+ ? await dependencies.claimNextJob(config)
58
+ : await acaServerRequest("POST", "/api/client/jobs/claim", {
59
+ machineId: config.machineId,
60
+ workspaceId: config.defaultWorkspaceId
61
+ });
62
+ if (!response.item)
63
+ break;
64
+ const job = response.item;
65
+ if (stopping) {
66
+ await (dependencies.cancelClaimedJob
67
+ ? dependencies.cancelClaimedJob(job, config)
68
+ : failJob(job.job_id, config.machineId, "cancelled")).catch(() => void 0);
69
+ break;
70
+ }
71
+ const sessionKey = job.session_id;
72
+ if (activeSessions.has(sessionKey)) {
73
+ logger.warn(`claimed job=${job.job_id} for already active session=${sessionKey}; running anyway to avoid leaving it stuck`);
74
+ }
75
+ activeJobs.add(job.job_id);
76
+ activeSessions.add(sessionKey);
77
+ const controller = new AbortController();
78
+ const promise = (dependencies.runJob ?? runClientJob)(job, config, logger, controller)
79
+ .catch((error) => {
80
+ logger.warn(`job ${job.job_id} failed outside handler: ${error instanceof Error ? error.message : String(error)}`);
81
+ })
82
+ .finally(() => {
83
+ activeJobs.delete(job.job_id);
84
+ activeSessions.delete(sessionKey);
85
+ activeRuns.delete(job.job_id);
86
+ });
87
+ activeRuns.set(job.job_id, { controller, promise });
88
+ }
89
+ }
90
+ catch (error) {
91
+ if (!stopping)
92
+ logger.warn(`job worker failed: ${error instanceof Error ? error.message : String(error)}`);
93
+ }
94
+ };
95
+ const scheduleTick = () => {
96
+ if (stopping || tickPromise)
97
+ return;
98
+ const promise = tick();
99
+ tickPromise = promise;
100
+ void promise.finally(() => {
101
+ if (tickPromise === promise)
102
+ tickPromise = null;
103
+ });
104
+ };
105
+ scheduleTick();
106
+ const timer = setInterval(() => {
107
+ scheduleTick();
108
+ }, dependencies.claimIntervalMs ?? CLAIM_INTERVAL_MS);
109
+ return {
110
+ timer,
111
+ stop: () => {
112
+ if (stopPromise)
113
+ return stopPromise;
114
+ stopPromise = (async () => {
115
+ if (stopping)
116
+ return;
117
+ stopping = true;
118
+ clearInterval(timer);
119
+ for (const run of activeRuns.values())
120
+ run.controller.abort();
121
+ await tickPromise?.catch(() => void 0);
122
+ for (const run of activeRuns.values())
123
+ run.controller.abort();
124
+ const pending = [...activeRuns.values()].map((run) => run.promise);
125
+ if (pending.length === 0)
126
+ return;
127
+ await Promise.race([
128
+ Promise.allSettled(pending).then(() => void 0),
129
+ delay(WORKER_STOP_TIMEOUT_MS)
130
+ ]);
131
+ })();
132
+ return stopPromise;
133
+ }
134
+ };
135
+ }
136
+ function getMaxConcurrentJobs() {
137
+ const raw = process.env.ACA_MAX_CONCURRENT_JOBS || process.env.ACA_MAX_CONCURRENT_PROJECT_JOBS || "";
138
+ const parsed = Number.parseInt(raw, 10);
139
+ if (!raw.trim() || !Number.isFinite(parsed) || parsed < 0)
140
+ return DEFAULT_MAX_CONCURRENT_JOBS;
141
+ if (parsed === 0)
142
+ return 0;
143
+ return Math.max(1, Math.min(parsed, 8));
144
+ }
145
+ function hasJobCapacity(activeCount, maxConcurrentJobs) {
146
+ return maxConcurrentJobs === 0 || activeCount < maxConcurrentJobs;
147
+ }
148
+ function getAcpEventBatchIntervalMs() {
149
+ const value = Number.parseInt(process.env.ACA_ACP_EVENT_BATCH_INTERVAL_MS || "", 10);
150
+ if (!Number.isFinite(value))
151
+ return DEFAULT_ACP_EVENT_BATCH_INTERVAL_MS;
152
+ return Math.max(40, Math.min(value, 2_000));
153
+ }
154
+ async function runClientJob(job, config, logger, controller) {
155
+ const jobId = job.job_id;
156
+ const sessionId = job.session_id;
157
+ const workspaceId = job.workspace_id;
158
+ const projectId = job.project_id ?? null;
159
+ const input = job.input ?? {};
160
+ const controlType = String(input.control?.type || "").toLowerCase();
161
+ if (!controlType && !input.message?.trim() && (input.attachments ?? []).length === 0) {
162
+ await failJob(jobId, config.machineId, "job input missing message");
163
+ return;
164
+ }
165
+ const project = config.projects.find((item) => item.projectId === projectId);
166
+ const projectRoot = expandProjectRoot(project?.rootPath || job.project_root_path || "");
167
+ if (!projectRoot) {
168
+ await failJob(jobId, config.machineId, "job project root is unknown");
169
+ return;
170
+ }
171
+ if (isChatProjectJob(job))
172
+ fs.mkdirSync(projectRoot, { recursive: true });
173
+ logger.info(`claimed job=${jobId} session=${sessionId} project=${project?.name ?? projectId ?? "unknown"}`);
174
+ const heartbeatTimer = setInterval(() => {
175
+ void acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/heartbeat`, {
176
+ machineId: config.machineId
177
+ }).catch(() => void 0);
178
+ void checkCancel(jobId, controller).catch(() => void 0);
179
+ }, JOB_HEARTBEAT_INTERVAL_MS);
180
+ let acpUpdateSequence = 0;
181
+ const eventBatcher = createAcpEventBatcher(jobId, config.machineId);
182
+ const presenceReporter = createPresenceReporter(jobId, config.machineId);
183
+ const affectedPathEvidence = new Set();
184
+ let preparedForResult = null;
185
+ try {
186
+ await presenceReporter.sendNow({
187
+ phase: "initializing",
188
+ label: "准备工作区",
189
+ detail: job.worktree_id ? "worktree" : "project root"
190
+ }).catch(() => void 0);
191
+ const prepared = prepareLocalWorktree({
192
+ projectId,
193
+ sessionId,
194
+ projectRoot,
195
+ worktree: job
196
+ });
197
+ preparedForResult = prepared;
198
+ await updateWorktree(jobId, config.machineId, prepared);
199
+ const acpRun = normalizeAcpRun(input.acpRun);
200
+ if (controlType === "compact") {
201
+ if (!job.acp_session_id)
202
+ throw new Error("Current session does not have an ACP context to compact");
203
+ const compacted = await compactAgentContext({
204
+ agentType: firstNonEmptyString(acpRun.agentType, "codex"),
205
+ cliType: firstNonEmptyString(acpRun.cliType, "builtin"),
206
+ acpSessionId: job.acp_session_id,
207
+ cwd: prepared.cwd,
208
+ model: input.model,
209
+ mode: input.mode,
210
+ configOptionValues: input.configOptionValues,
211
+ timeoutMs: input.timeoutMs,
212
+ allowRollover: input.control?.allowRollover === true,
213
+ signal: controller.signal,
214
+ onStatus: (status) => {
215
+ presenceReporter.report({
216
+ phase: status.phase,
217
+ label: status.label,
218
+ detail: status.detail,
219
+ updateType: status.updateType,
220
+ raw: status
221
+ });
222
+ }
223
+ });
224
+ await presenceReporter.flush();
225
+ await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/complete`, {
226
+ machineId: config.machineId,
227
+ acpSessionId: compacted.acpSessionId,
228
+ suppressTurn: true,
229
+ result: {
230
+ ok: true,
231
+ control: { type: "compact", allowRollover: input.control?.allowRollover === true },
232
+ acpSessionId: compacted.acpSessionId,
233
+ durationMs: compacted.durationMs,
234
+ usage: compacted.contextUsage,
235
+ maintenance: compacted.maintenance,
236
+ worktree: summarizePreparedWorktree(prepared)
237
+ }
238
+ });
239
+ logger.info(`completed context compact job=${jobId}`);
240
+ return;
241
+ }
242
+ const materializedAttachments = materializeJobAttachments({
243
+ cwd: prepared.cwd,
244
+ sessionId,
245
+ jobId,
246
+ attachments: input.attachments ?? []
247
+ });
248
+ if (materializedAttachments.length > 0) {
249
+ logger.info(`materialized ${materializedAttachments.length} attachment(s) for job=${jobId}`);
250
+ await presenceReporter.sendNow({
251
+ phase: "initializing",
252
+ label: "附件已写入本机",
253
+ detail: materializedAttachments.map((item) => item.relativePath).join(", "),
254
+ raw: { attachments: summarizeMaterializedAttachments(materializedAttachments) }
255
+ }).catch(() => void 0);
256
+ }
257
+ const prompt = buildPromptWithAttachments(input, materializedAttachments);
258
+ const promptBlocks = buildPromptBlocks(prompt, materializedAttachments);
259
+ const agent = await sendAgentPrompt({
260
+ agentType: firstNonEmptyString(acpRun.agentType, "codex"),
261
+ cliType: firstNonEmptyString(acpRun.cliType, "builtin"),
262
+ acpSessionId: job.acp_session_id ?? null,
263
+ prompt,
264
+ promptBlocks,
265
+ cwd: prepared.cwd,
266
+ model: input.model,
267
+ mode: input.mode,
268
+ configOptionValues: input.configOptionValues,
269
+ canExecuteTools: input.canExecuteTools,
270
+ timeoutMs: input.timeoutMs,
271
+ contextWindow: positiveNumber(acpRun.contextWindow),
272
+ signal: controller.signal,
273
+ onStatus: (status) => {
274
+ presenceReporter.report({
275
+ phase: status.phase,
276
+ label: status.label,
277
+ detail: status.detail,
278
+ updateType: status.updateType,
279
+ raw: status
280
+ });
281
+ },
282
+ onUpdate: (event) => {
283
+ acpUpdateSequence += 1;
284
+ for (const filePath of extractAffectedFilePathsFromAcpRaw(event.raw)) {
285
+ affectedPathEvidence.add(filePath);
286
+ }
287
+ const outgoingEvent = {
288
+ turnId: job.user_turn_id ?? null,
289
+ eventType: event.type,
290
+ label: event.label,
291
+ text: event.text,
292
+ status: event.status,
293
+ toolCallId: event.toolCallId,
294
+ raw: withAcaSequence(event.raw, acpUpdateSequence)
295
+ };
296
+ if (shouldUploadAcpEvent(outgoingEvent))
297
+ eventBatcher.push(outgoingEvent);
298
+ },
299
+ onPermissionRequest: (request) => {
300
+ const policyResponse = resolveJobPermissionPolicy(input, request);
301
+ return policyResponse
302
+ ? Promise.resolve(policyResponse)
303
+ : waitForPermission(jobId, config.machineId, request, controller.signal);
304
+ },
305
+ onUserInputRequest: (request) => waitForUserInput(jobId, config.machineId, request, controller.signal)
306
+ });
307
+ await eventBatcher.flush();
308
+ await presenceReporter.flush();
309
+ const affectedFiles = collectAffectedFiles({
310
+ cwd: prepared.cwd,
311
+ evidencePaths: affectedPathEvidence
312
+ });
313
+ const stopReason = agentStopReason(agent.promptResponse);
314
+ const ok = isAgentRunSuccessful(stopReason) || Boolean(agent.content);
315
+ if (!ok) {
316
+ await failJob(jobId, config.machineId, `${agent.agentType} stopped: ${stopReason ?? "unknown"}`, {
317
+ acpSessionId: agent.acpSessionId,
318
+ stopReason,
319
+ exitCode: agent.exitCode,
320
+ stderr: truncate(agent.stderr, 4_000),
321
+ worktree: summarizePreparedWorktree(prepared)
322
+ });
323
+ return;
324
+ }
325
+ await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/complete`, {
326
+ machineId: config.machineId,
327
+ acpSessionId: agent.acpSessionId,
328
+ assistantText: agent.content,
329
+ result: {
330
+ ok: true,
331
+ acpSessionId: agent.acpSessionId,
332
+ stopReason,
333
+ durationMs: agent.durationMs,
334
+ acpRun: input.acpRun ?? null,
335
+ effectiveConfig: agent.effectiveConfig ?? null,
336
+ performance: agent.performance ?? null,
337
+ usage: agent.contextUsage ?? null,
338
+ updateCount: agent.updates.length,
339
+ statusTimeline: agent.statusTimeline,
340
+ worktree: summarizePreparedWorktree(prepared),
341
+ attachments: summarizeMaterializedAttachments(materializedAttachments),
342
+ files: affectedFiles
343
+ }
344
+ });
345
+ logger.info(`completed job=${jobId}`);
346
+ }
347
+ catch (error) {
348
+ await eventBatcher.flush();
349
+ await presenceReporter.flush();
350
+ const rawMessage = controller.signal.aborted ? "cancelled" : error instanceof Error ? error.message : String(error);
351
+ const affectedFiles = preparedForResult ? collectAffectedFiles({
352
+ cwd: preparedForResult.cwd,
353
+ evidencePaths: affectedPathEvidence
354
+ }) : [];
355
+ await failJob(jobId, config.machineId, userFacingAcpError(rawMessage), { rawError: rawMessage, files: affectedFiles });
356
+ }
357
+ finally {
358
+ await eventBatcher.flush();
359
+ await presenceReporter.flush();
360
+ clearInterval(heartbeatTimer);
361
+ }
362
+ }
363
+ function userFacingAcpError(message) {
364
+ if (/ACP request timed out/i.test(message))
365
+ return "ACP 长时间没有完成,请稍后重试,或点击停止后重新发送。";
366
+ if (/MimoCode request timed out/i.test(message))
367
+ return "MimoCode 长时间没有完成,请稍后重试,或点击停止后重新发送。";
368
+ return message.replace(/\bsession\/prompt\b/g, "ACP 请求");
369
+ }
370
+ function normalizeAcpRun(value) {
371
+ if (!value || typeof value !== "object" || Array.isArray(value))
372
+ return {};
373
+ return value;
374
+ }
375
+ function positiveNumber(value) {
376
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
377
+ }
378
+ function firstNonEmptyString(...values) {
379
+ for (const value of values) {
380
+ if (typeof value === "string" && value.trim())
381
+ return value.trim();
382
+ }
383
+ return "";
384
+ }
385
+ function agentStopReason(promptResponse) {
386
+ if (!promptResponse || typeof promptResponse !== "object" || Array.isArray(promptResponse))
387
+ return undefined;
388
+ const record = promptResponse;
389
+ if (typeof record.stopReason === "string")
390
+ return record.stopReason;
391
+ if (typeof record.reason === "string")
392
+ return record.reason;
393
+ const part = record.part;
394
+ if (part && typeof part === "object" && !Array.isArray(part) && typeof part.reason === "string") {
395
+ return part.reason;
396
+ }
397
+ return undefined;
398
+ }
399
+ function isAgentRunSuccessful(stopReason) {
400
+ if (!stopReason)
401
+ return false;
402
+ return ["end_turn", "stop", "completed", "complete"].includes(stopReason);
403
+ }
404
+ function createAcpEventBatcher(jobId, machineId) {
405
+ let buffer = [];
406
+ let timer = null;
407
+ let chain = Promise.resolve();
408
+ const send = async (events) => {
409
+ if (events.length === 0)
410
+ return;
411
+ await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/events/batch`, {
412
+ machineId,
413
+ events
414
+ });
415
+ };
416
+ const flushNow = () => {
417
+ if (timer) {
418
+ clearTimeout(timer);
419
+ timer = null;
420
+ }
421
+ const events = buffer;
422
+ buffer = [];
423
+ chain = chain.then(() => send(events)).catch(() => void 0);
424
+ return chain;
425
+ };
426
+ const schedule = () => {
427
+ if (timer)
428
+ return;
429
+ timer = setTimeout(() => {
430
+ void flushNow();
431
+ }, getAcpEventBatchIntervalMs());
432
+ timer.unref();
433
+ };
434
+ return {
435
+ push(event) {
436
+ appendCoalescedAcpEvent(buffer, event);
437
+ if (buffer.length >= ACP_EVENT_BATCH_MAX_SIZE) {
438
+ void flushNow();
439
+ return;
440
+ }
441
+ schedule();
442
+ },
443
+ flush: flushNow
444
+ };
445
+ }
446
+ function createPresenceReporter(jobId, machineId) {
447
+ let pending = null;
448
+ let lastSentAt = 0;
449
+ let lastSignature = "";
450
+ let timer = null;
451
+ let chain = Promise.resolve();
452
+ const signatureOf = (payload) => {
453
+ return JSON.stringify({
454
+ phase: payload.phase ?? "",
455
+ label: payload.label ?? "",
456
+ detail: payload.detail ?? "",
457
+ updateType: payload.updateType ?? ""
458
+ });
459
+ };
460
+ const post = async (payload) => {
461
+ await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/presence`, {
462
+ machineId,
463
+ phase: payload.phase,
464
+ label: payload.label,
465
+ detail: payload.detail,
466
+ updateType: payload.updateType,
467
+ raw: payload.raw
468
+ });
469
+ lastSentAt = Date.now();
470
+ lastSignature = signatureOf(payload);
471
+ };
472
+ const flushPending = () => {
473
+ if (timer) {
474
+ clearTimeout(timer);
475
+ timer = null;
476
+ }
477
+ if (!pending)
478
+ return chain;
479
+ const payload = pending;
480
+ pending = null;
481
+ chain = chain.then(() => post(payload)).catch(() => void 0);
482
+ return chain;
483
+ };
484
+ const schedule = (delay) => {
485
+ if (timer)
486
+ return;
487
+ timer = setTimeout(() => {
488
+ void flushPending();
489
+ }, delay);
490
+ timer.unref();
491
+ };
492
+ return {
493
+ report(payload) {
494
+ const now = Date.now();
495
+ const signature = signatureOf(payload);
496
+ const changed = signature !== lastSignature;
497
+ const stale = now - lastSentAt >= ACP_PRESENCE_KEEPALIVE_MS;
498
+ if (!changed && !stale)
499
+ return;
500
+ pending = payload;
501
+ const elapsed = now - lastSentAt;
502
+ if (elapsed >= ACP_PRESENCE_MIN_INTERVAL_MS || lastSentAt === 0) {
503
+ void flushPending();
504
+ return;
505
+ }
506
+ schedule(ACP_PRESENCE_MIN_INTERVAL_MS - elapsed);
507
+ },
508
+ sendNow(payload) {
509
+ pending = payload;
510
+ return flushPending();
511
+ },
512
+ flush: flushPending
513
+ };
514
+ }
515
+ function withAcaSequence(raw, sequence) {
516
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
517
+ return { ...raw, acaSequence: sequence };
518
+ }
519
+ return { value: raw, acaSequence: sequence };
520
+ }
521
+ function buildPromptWithAttachments(input, materializedAttachments = []) {
522
+ const message = input?.message?.trim() ?? "";
523
+ if (materializedAttachments.length === 0)
524
+ return message;
525
+ const lines = materializedAttachments.map((attachment, index) => {
526
+ return `${index + 1}. ${attachment.name} (${attachment.kind}, ${attachment.mimeType}, ${formatByteSize(attachment.size)})\n localPath: ${attachment.path}\n workspacePath: ${attachment.relativePath}`;
527
+ });
528
+ const attachmentText = [
529
+ "用户随消息上传了以下附件,aca 已将附件写入本机工作区。需要读取附件时请直接使用下面的 localPath;图片附件也已作为 ACP image block 随本轮请求传入。",
530
+ ...lines
531
+ ].join("\n");
532
+ return message ? `${message}\n\n${attachmentText}` : attachmentText;
533
+ }
534
+ function buildPromptBlocks(prompt, attachments) {
535
+ const blocks = [{ type: "text", text: prompt }];
536
+ for (const attachment of attachments) {
537
+ if (attachment.kind !== "image" || !attachment.mimeType.startsWith("image/"))
538
+ continue;
539
+ blocks.push({
540
+ type: "image",
541
+ data: attachment.base64,
542
+ mimeType: attachment.mimeType,
543
+ uri: attachment.path
544
+ });
545
+ }
546
+ return blocks;
547
+ }
548
+ function materializeJobAttachments(input) {
549
+ if (input.attachments.length === 0)
550
+ return [];
551
+ const root = path.join(input.cwd, ".aca", "attachments", sanitizePathSegment(input.sessionId), sanitizePathSegment(input.jobId));
552
+ fs.mkdirSync(root, { recursive: true });
553
+ return input.attachments.map((attachment, index) => {
554
+ const parsed = parseDataUrl(attachment.dataUrl ?? "");
555
+ const mimeType = attachment.mimeType || parsed.mimeType || "application/octet-stream";
556
+ const kind = attachment.kind === "image" || mimeType.startsWith("image/") ? "image" : "file";
557
+ const safeName = safeAttachmentFileName(attachment.name || `attachment-${index + 1}`, mimeType);
558
+ const filePath = path.join(root, `${String(index + 1).padStart(2, "0")}-${safeName}`);
559
+ fs.writeFileSync(filePath, parsed.bytes);
560
+ return {
561
+ name: attachment.name || safeName,
562
+ mimeType,
563
+ size: parsed.bytes.byteLength,
564
+ kind,
565
+ path: filePath,
566
+ relativePath: path.relative(input.cwd, filePath),
567
+ base64: parsed.bytes.toString("base64"),
568
+ sha256: crypto.createHash("sha256").update(parsed.bytes).digest("hex")
569
+ };
570
+ });
571
+ }
572
+ function parseDataUrl(dataUrl) {
573
+ const commaIndex = dataUrl.indexOf(",");
574
+ if (!dataUrl.startsWith("data:") || commaIndex < 0) {
575
+ throw new Error("attachment dataUrl is not a data URL");
576
+ }
577
+ const header = dataUrl.slice(5, commaIndex);
578
+ const body = dataUrl.slice(commaIndex + 1);
579
+ const parts = header.split(";").filter(Boolean);
580
+ const mimeType = parts[0]?.includes("/") ? parts[0] : "application/octet-stream";
581
+ if (!parts.includes("base64")) {
582
+ throw new Error("attachment dataUrl is not base64 encoded");
583
+ }
584
+ return {
585
+ mimeType,
586
+ bytes: Buffer.from(body, "base64")
587
+ };
588
+ }
589
+ function safeAttachmentFileName(name, mimeType) {
590
+ const cleaned = path.basename(name).replace(/[<>:"/\\|?*\x00-\x1F]/g, "_").trim();
591
+ const fallback = `attachment${extensionForMimeType(mimeType)}`;
592
+ const safe = !cleaned || cleaned === "." || cleaned === ".." ? fallback : cleaned;
593
+ return safe.slice(0, 180);
594
+ }
595
+ function extensionForMimeType(mimeType) {
596
+ switch (mimeType.toLowerCase()) {
597
+ case "image/png":
598
+ return ".png";
599
+ case "image/jpeg":
600
+ return ".jpg";
601
+ case "image/webp":
602
+ return ".webp";
603
+ case "image/gif":
604
+ return ".gif";
605
+ case "text/plain":
606
+ return ".txt";
607
+ case "application/json":
608
+ return ".json";
609
+ case "text/markdown":
610
+ case "text/x-markdown":
611
+ return ".md";
612
+ case "application/pdf":
613
+ return ".pdf";
614
+ case "application/msword":
615
+ return ".doc";
616
+ case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
617
+ return ".docx";
618
+ case "application/vnd.ms-excel":
619
+ return ".xls";
620
+ case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
621
+ return ".xlsx";
622
+ case "application/vnd.ms-powerpoint":
623
+ return ".ppt";
624
+ case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
625
+ return ".pptx";
626
+ case "application/zip":
627
+ return ".zip";
628
+ case "application/x-7z-compressed":
629
+ return ".7z";
630
+ case "application/x-rar-compressed":
631
+ case "application/vnd.rar":
632
+ return ".rar";
633
+ case "application/gzip":
634
+ return ".gz";
635
+ case "application/x-tar":
636
+ return ".tar";
637
+ default:
638
+ return "";
639
+ }
640
+ }
641
+ function sanitizePathSegment(value) {
642
+ return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "item";
643
+ }
644
+ function summarizeMaterializedAttachments(attachments) {
645
+ return attachments.map((attachment) => ({
646
+ name: attachment.name,
647
+ kind: attachment.kind,
648
+ mimeType: attachment.mimeType,
649
+ size: attachment.size,
650
+ path: attachment.path,
651
+ relativePath: attachment.relativePath,
652
+ sha256: attachment.sha256
653
+ }));
654
+ }
655
+ function formatByteSize(size) {
656
+ if (!Number.isFinite(size) || size <= 0)
657
+ return "0 B";
658
+ const units = ["B", "KB", "MB", "GB"];
659
+ let value = size;
660
+ let unitIndex = 0;
661
+ while (value >= 1024 && unitIndex < units.length - 1) {
662
+ value /= 1024;
663
+ unitIndex += 1;
664
+ }
665
+ return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
666
+ }
667
+ async function updateWorktree(jobId, machineId, prepared) {
668
+ if (!prepared.usedWorktree || !prepared.worktreeId)
669
+ return;
670
+ await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/worktree`, {
671
+ machineId,
672
+ baseCommit: prepared.baseCommit ?? null,
673
+ worktreePath: prepared.cwd,
674
+ status: prepared.status ?? "active",
675
+ raw: prepared.raw ?? summarizePreparedWorktree(prepared)
676
+ });
677
+ }
678
+ function summarizePreparedWorktree(prepared) {
679
+ return {
680
+ usedWorktree: prepared.usedWorktree,
681
+ worktreeId: prepared.worktreeId ?? null,
682
+ cwd: prepared.cwd,
683
+ status: prepared.status ?? null,
684
+ baseCommit: prepared.baseCommit ?? null,
685
+ branchName: prepared.branchName ?? null,
686
+ baseBranch: prepared.baseBranch ?? null
687
+ };
688
+ }
689
+ async function waitForPermission(jobId, machineId, request, signal) {
690
+ const created = await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/permissions`, {
691
+ machineId,
692
+ request
693
+ });
694
+ const permissionId = created.item.permission_id;
695
+ while (!signal.aborted) {
696
+ await delay(PERMISSION_POLL_INTERVAL_MS);
697
+ const response = await acaServerRequest("GET", `/api/client/permissions/${encodeURIComponent(permissionId)}`);
698
+ if (response.item.status === "resolved" && response.item.response) {
699
+ const outcome = response.item.response.outcome;
700
+ if (outcome.outcome === "selected")
701
+ return { outcome };
702
+ return { outcome: { outcome: "cancelled" } };
703
+ }
704
+ }
705
+ return { outcome: { outcome: "cancelled" } };
706
+ }
707
+ async function waitForUserInput(jobId, machineId, request, signal) {
708
+ const created = await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/permissions`, {
709
+ machineId,
710
+ request: {
711
+ kind: "elicitation",
712
+ ...request
713
+ }
714
+ });
715
+ const permissionId = created.item.permission_id;
716
+ while (!signal.aborted) {
717
+ await delay(PERMISSION_POLL_INTERVAL_MS);
718
+ const response = await acaServerRequest("GET", `/api/client/permissions/${encodeURIComponent(permissionId)}`);
719
+ if (response.item.status === "resolved" && response.item.response) {
720
+ const outcome = response.item.response.outcome;
721
+ if (outcome.outcome === "submitted")
722
+ return { outcome };
723
+ return { outcome: { outcome: "cancelled" } };
724
+ }
725
+ }
726
+ return { outcome: { outcome: "cancelled" } };
727
+ }
728
+ async function checkCancel(jobId, controller) {
729
+ if (controller.signal.aborted)
730
+ return;
731
+ const response = await acaServerRequest("GET", `/api/client/jobs/${encodeURIComponent(jobId)}`);
732
+ if (Number(response.item.cancel_requested ?? 0) === 1)
733
+ controller.abort();
734
+ }
735
+ async function failJob(jobId, machineId, error, result) {
736
+ await acaServerRequest("POST", `/api/client/jobs/${encodeURIComponent(jobId)}/fail`, {
737
+ machineId,
738
+ error,
739
+ result
740
+ });
741
+ }
742
+ function delay(ms) {
743
+ return new Promise((resolve) => setTimeout(resolve, ms));
744
+ }
745
+ function truncate(value, maxChars) {
746
+ if (value.length <= maxChars)
747
+ return value;
748
+ return `${value.slice(0, maxChars)}...`;
749
+ }