@agent-compose/sdk 0.8.2 → 0.8.3
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/dist/agent/__tests__/perf-sampler.test.d.ts +10 -0
- package/dist/agent/agent-context.d.ts +1 -1
- package/dist/agent/agent-loop.d.ts +5 -1
- package/dist/agent/desktop-open.d.ts +184 -0
- package/dist/agent/perf-sampler.d.ts +99 -0
- package/dist/agent/services-manifest.d.ts +88 -0
- package/dist/agent/services-restore.d.ts +58 -0
- package/dist/client.d.ts +164 -8
- package/dist/display.d.ts +17 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +1374 -51
- package/dist/runtimes/_cli-agent.d.ts +347 -2
- package/dist/runtimes/claude-code.d.ts +12 -0
- package/dist/runtimes/codex.d.ts +8 -0
- package/dist/runtimes/openai-desktop.js +1312 -51
- package/dist/runtimes/session-env.test.d.ts +14 -0
- package/dist/types/api-conversations.d.ts +309 -1
- package/dist/types/api-factory.d.ts +115 -10
- package/dist/types/api-runs.d.ts +21 -0
- package/dist/types/protocol.d.ts +32 -1
- package/dist/types/runtime.d.ts +120 -0
- package/package.json +1 -1
- package/src/agent/agent-context.ts +100 -11
- package/src/agent/agent-loop.ts +10 -3
- package/src/agent/desktop-open.ts +418 -0
- package/src/agent/perf-sampler.ts +202 -0
- package/src/agent/services-manifest.ts +356 -0
- package/src/agent/services-restore.ts +195 -0
- package/src/client.ts +328 -12
- package/src/display.ts +44 -1
- package/src/index.ts +63 -1
- package/src/runtimes/_cli-agent.ts +891 -35
- package/src/runtimes/claude-code.ts +187 -12
- package/src/runtimes/codex.ts +58 -1
- package/src/sandbox/providers/local.ts +16 -4
- package/src/types/api-conversations.ts +307 -3
- package/src/types/api-factory.ts +118 -10
- package/src/types/api-runs.ts +23 -0
- package/src/types/protocol.ts +30 -1
- package/src/types/runtime.ts +122 -0
|
@@ -1608,6 +1608,9 @@ class AgentComposeClient {
|
|
|
1608
1608
|
async getRunFunding(runId) {
|
|
1609
1609
|
return this.fetch(`/api/v1/workflows/${encodeURIComponent(runId)}/funding`);
|
|
1610
1610
|
}
|
|
1611
|
+
async getRunStepUsage(runId) {
|
|
1612
|
+
return this.fetch(`/api/v1/workflows/${encodeURIComponent(runId)}/step-usage`);
|
|
1613
|
+
}
|
|
1611
1614
|
async reportEvent(runId, input) {
|
|
1612
1615
|
const body = {
|
|
1613
1616
|
...input,
|
|
@@ -1663,6 +1666,8 @@ class AgentComposeClient {
|
|
|
1663
1666
|
q2.set("revision", String(opts.revision));
|
|
1664
1667
|
if (opts?.branch !== undefined)
|
|
1665
1668
|
q2.set("branch", opts.branch);
|
|
1669
|
+
if (opts?.at !== undefined)
|
|
1670
|
+
q2.set("at", opts.at);
|
|
1666
1671
|
const buf = await this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/files/content?${q2}`, { responseType: "arrayBuffer" });
|
|
1667
1672
|
return new Uint8Array(buf);
|
|
1668
1673
|
}
|
|
@@ -1695,6 +1700,22 @@ class AgentComposeClient {
|
|
|
1695
1700
|
q2.set("cursor", opts.cursor);
|
|
1696
1701
|
return this.fetch(`/api/v1/sessions?${q2}`);
|
|
1697
1702
|
}
|
|
1703
|
+
getSessionPerfHistory(conversationId, opts) {
|
|
1704
|
+
const q2 = new URLSearchParams;
|
|
1705
|
+
if (opts?.limit !== undefined)
|
|
1706
|
+
q2.set("limit", String(opts.limit));
|
|
1707
|
+
if (opts?.cursor)
|
|
1708
|
+
q2.set("cursor", opts.cursor);
|
|
1709
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/perf-history${q2.toString() ? `?${q2}` : ""}`);
|
|
1710
|
+
}
|
|
1711
|
+
getFactoryPerfSummary(factorySlug, opts) {
|
|
1712
|
+
const q2 = new URLSearchParams;
|
|
1713
|
+
if (opts?.from)
|
|
1714
|
+
q2.set("from", opts.from);
|
|
1715
|
+
if (opts?.to)
|
|
1716
|
+
q2.set("to", opts.to);
|
|
1717
|
+
return this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/perf-summary${q2.toString() ? `?${q2}` : ""}`);
|
|
1718
|
+
}
|
|
1698
1719
|
getConversation(id, opts) {
|
|
1699
1720
|
const q2 = opts?.limit !== undefined ? `?limit=${opts.limit}` : "";
|
|
1700
1721
|
return this.fetch(`/api/v1/conversations/${encodeURIComponent(id)}${q2}`);
|
|
@@ -1738,6 +1759,27 @@ class AgentComposeClient {
|
|
|
1738
1759
|
postSessionChannelMessage(channelId, input) {
|
|
1739
1760
|
return this.fetch(`/api/v1/conversations/${encodeURIComponent(channelId)}/session-messages`, { method: "POST", body: { text: input.text, ...input.threadRootId ? { threadRootId: input.threadRootId } : {} } });
|
|
1740
1761
|
}
|
|
1762
|
+
sendSessionMessage(input) {
|
|
1763
|
+
return this.fetch("/api/v1/session-messages", {
|
|
1764
|
+
method: "POST",
|
|
1765
|
+
body: input
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
claimSessionBranch(input) {
|
|
1769
|
+
return this.fetch("/api/v1/session-branch-claims", {
|
|
1770
|
+
method: "POST",
|
|
1771
|
+
body: { target: input.target, ...input.reason ? { reason: input.reason } : {} }
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
releaseSessionBranchClaims(input = {}) {
|
|
1775
|
+
return this.fetch("/api/v1/session-branch-claims/release", {
|
|
1776
|
+
method: "POST",
|
|
1777
|
+
body: { ...input.target ? { target: input.target } : {} }
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
getSessionBranchClaimState(conversationId) {
|
|
1781
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/branch-claim`);
|
|
1782
|
+
}
|
|
1741
1783
|
openPreview(conversationId, input) {
|
|
1742
1784
|
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/preview`, {
|
|
1743
1785
|
method: "POST",
|
|
@@ -1766,8 +1808,33 @@ class AgentComposeClient {
|
|
|
1766
1808
|
forkSession(conversationId, input) {
|
|
1767
1809
|
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/fork`, { method: "POST", body: input?.title ? { title: input.title } : {} });
|
|
1768
1810
|
}
|
|
1769
|
-
getSessionChanges(conversationId) {
|
|
1770
|
-
|
|
1811
|
+
getSessionChanges(conversationId, opts = {}) {
|
|
1812
|
+
const q2 = new URLSearchParams;
|
|
1813
|
+
if (opts.after !== undefined)
|
|
1814
|
+
q2.set("after", opts.after);
|
|
1815
|
+
if (opts.limit !== undefined)
|
|
1816
|
+
q2.set("limit", String(opts.limit));
|
|
1817
|
+
const tail = q2.toString();
|
|
1818
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes${tail ? `?${tail}` : ""}`);
|
|
1819
|
+
}
|
|
1820
|
+
async getSessionChangeContent(conversationId, path, opts = {}) {
|
|
1821
|
+
const q2 = new URLSearchParams({ path });
|
|
1822
|
+
if (opts.side !== undefined)
|
|
1823
|
+
q2.set("side", opts.side);
|
|
1824
|
+
const buf = await this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/content?${q2}`, { responseType: "arrayBuffer" });
|
|
1825
|
+
return new Uint8Array(buf);
|
|
1826
|
+
}
|
|
1827
|
+
publishReviewNotes(conversationId, input) {
|
|
1828
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/review-notes`, {
|
|
1829
|
+
method: "POST",
|
|
1830
|
+
body: {
|
|
1831
|
+
notes: input.notes,
|
|
1832
|
+
...input.suggestions !== undefined ? { suggestions: input.suggestions } : {}
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
getReviewGitCredential(conversationId) {
|
|
1837
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/review-git-credential`, { method: "POST" });
|
|
1771
1838
|
}
|
|
1772
1839
|
mergeSessionChanges(conversationId, opts = {}) {
|
|
1773
1840
|
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/merge`, { method: "POST", body: opts.branch ? { branch: opts.branch } : {} });
|
|
@@ -1775,6 +1842,15 @@ class AgentComposeClient {
|
|
|
1775
1842
|
discardSessionChanges(conversationId, opts = {}) {
|
|
1776
1843
|
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/discard`, { method: "POST", body: opts.branch ? { branch: opts.branch } : {} });
|
|
1777
1844
|
}
|
|
1845
|
+
getSessionChangePreflight(conversationId) {
|
|
1846
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/preflight`);
|
|
1847
|
+
}
|
|
1848
|
+
rebaseSessionChanges(conversationId, opts = {}) {
|
|
1849
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/rebase`, { method: "POST", body: opts.branch ? { branch: opts.branch } : {} });
|
|
1850
|
+
}
|
|
1851
|
+
setSessionAutoRebase(conversationId, enabled) {
|
|
1852
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/autorebase`, { method: "POST", body: { enabled } });
|
|
1853
|
+
}
|
|
1778
1854
|
async getConversationMembers(conversationId) {
|
|
1779
1855
|
const body = await this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/members`);
|
|
1780
1856
|
return body.members;
|
|
@@ -1829,6 +1905,28 @@ class AgentComposeClient {
|
|
|
1829
1905
|
q2.set("cursor", opts.cursor);
|
|
1830
1906
|
return this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/files/search?${q2}`);
|
|
1831
1907
|
}
|
|
1908
|
+
async listFactoryFiles(opts) {
|
|
1909
|
+
const factorySlug = opts?.factorySlug ?? (typeof process !== "undefined" ? process.env?.AGENT_COMPOSE_FACTORY : undefined) ?? DEFAULT_FACTORY;
|
|
1910
|
+
const q2 = new URLSearchParams;
|
|
1911
|
+
if (opts?.prefix)
|
|
1912
|
+
q2.set("prefix", opts.prefix);
|
|
1913
|
+
if (opts?.cursor)
|
|
1914
|
+
q2.set("cursor", opts.cursor);
|
|
1915
|
+
if (opts?.limit != null)
|
|
1916
|
+
q2.set("limit", String(opts.limit));
|
|
1917
|
+
if (opts?.branch !== undefined)
|
|
1918
|
+
q2.set("branch", opts.branch);
|
|
1919
|
+
if (opts?.at !== undefined)
|
|
1920
|
+
q2.set("at", opts.at);
|
|
1921
|
+
const qs = q2.toString();
|
|
1922
|
+
const wire = await this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/files${qs ? `?${qs}` : ""}`);
|
|
1923
|
+
return {
|
|
1924
|
+
data: wire.data,
|
|
1925
|
+
hasMore: wire.has_more,
|
|
1926
|
+
nextCursor: wire.next_cursor,
|
|
1927
|
+
at: wire.at ? { head: wire.at.head, committedAt: wire.at.committed_at } : null
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1832
1930
|
async listMembers() {
|
|
1833
1931
|
const body = await this.fetch("/api/v1/team/members");
|
|
1834
1932
|
return body.members;
|
|
@@ -2033,6 +2131,23 @@ class AgentComposeClient {
|
|
|
2033
2131
|
const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
|
|
2034
2132
|
return this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/secrets/${encodeURIComponent(key)}`, { method: "DELETE" });
|
|
2035
2133
|
}
|
|
2134
|
+
async listSessionSecrets(conversationId) {
|
|
2135
|
+
const body = await this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/secrets`);
|
|
2136
|
+
return body.secrets;
|
|
2137
|
+
}
|
|
2138
|
+
async setSessionSecrets(conversationId, secrets) {
|
|
2139
|
+
const body = await this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/secrets`, { method: "POST", body: { secrets } });
|
|
2140
|
+
return body.set;
|
|
2141
|
+
}
|
|
2142
|
+
deleteSessionSecret(conversationId, key) {
|
|
2143
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/secrets/${encodeURIComponent(key)}`, { method: "DELETE" });
|
|
2144
|
+
}
|
|
2145
|
+
requestSessionSecrets(conversationId, keys, opts) {
|
|
2146
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/secret-requests`, { method: "POST", body: { keys, ...opts?.reason ? { reason: opts.reason } : {} } });
|
|
2147
|
+
}
|
|
2148
|
+
getSessionSecretRequest(conversationId, requestId) {
|
|
2149
|
+
return this.fetch(`/api/v1/conversations/${encodeURIComponent(conversationId)}/secret-requests/${encodeURIComponent(requestId)}`);
|
|
2150
|
+
}
|
|
2036
2151
|
createApiKey(input) {
|
|
2037
2152
|
return this.fetch("/api-keys", { method: "POST", body: input });
|
|
2038
2153
|
}
|
|
@@ -3298,7 +3413,7 @@ Re-emit the COMPLETE corrected <response> JSON now: every required field present
|
|
|
3298
3413
|
continue;
|
|
3299
3414
|
}
|
|
3300
3415
|
const msg = outputVerdict.value;
|
|
3301
|
-
if (msg.type === "text_delta" || msg.type === "usage_delta")
|
|
3416
|
+
if (msg.type === "text_delta" || msg.type === "usage_delta" || msg.type === "task_notification")
|
|
3302
3417
|
continue;
|
|
3303
3418
|
opts.onAgentEvent?.(iteration, msg);
|
|
3304
3419
|
const summary = summarizeAgentMessage(msg);
|
|
@@ -3683,6 +3798,73 @@ function createClaudeRuntime(config = {}) {
|
|
|
3683
3798
|
});
|
|
3684
3799
|
}
|
|
3685
3800
|
var claude_default = createClaudeRuntime();
|
|
3801
|
+
// src/agent/perf-sampler.ts
|
|
3802
|
+
var PERF_SAMPLE_EVERY_BEATS = 6;
|
|
3803
|
+
var PERF_PROBE_WINDOW_SECONDS = 2;
|
|
3804
|
+
var PERF_PROBE_LINE_PREFIX = "perf ";
|
|
3805
|
+
var PERF_TOKEN_MAX_CHARS = 200;
|
|
3806
|
+
function perfReadFunctionFragment(procRoot, diskPath) {
|
|
3807
|
+
const stat = shellQuote(`${procRoot}/stat`);
|
|
3808
|
+
const loadavg = shellQuote(`${procRoot}/loadavg`);
|
|
3809
|
+
const meminfo = shellQuote(`${procRoot}/meminfo`);
|
|
3810
|
+
const disk = shellQuote(diskPath);
|
|
3811
|
+
return `ac_perf_read() { ` + `IFS=' ' read -r ac_pl ac_pu ac_pn2 ac_psy ac_pid ac_pio ac_pirq ac_psq ac_pst ac_prest 2>/dev/null < ${stat} || return 0; ` + `ac_pbz=$(( \${ac_pu:-0} + \${ac_pn2:-0} + \${ac_psy:-0} + \${ac_pirq:-0} + \${ac_psq:-0} + \${ac_pst:-0} )); ` + `ac_ptt=$(( ac_pbz + \${ac_pid:-0} + \${ac_pio:-0} )); ` + `ac_pcpu=-1; ` + `if [ "\${ac_ppt:-0}" -gt 0 ] && [ "$ac_ptt" -gt "\${ac_ppt:-0}" ]; then ` + `ac_pcpu=$(( 100 * (ac_pbz - \${ac_ppb:-0}) / (ac_ptt - ac_ppt) )); ` + `[ "$ac_pcpu" -lt 0 ] && ac_pcpu=0; [ "$ac_pcpu" -gt 100 ] && ac_pcpu=100; ` + `fi; ` + `ac_ppb=$ac_pbz; ac_ppt=$ac_ptt; ` + `IFS=' ' read -r ac_pl1 ac_plr 2>/dev/null < ${loadavg} || ac_pl1=-1; ` + `ac_pvc=$(grep -c '^cpu[0-9]' ${stat} 2>/dev/null); [ -n "$ac_pvc" ] || ac_pvc=0; ` + `ac_pmt=$(awk '$1=="MemTotal:"{print $2}' ${meminfo} 2>/dev/null); ` + `ac_pma=$(awk '$1=="MemAvailable:"{print $2}' ${meminfo} 2>/dev/null); ` + `ac_pmem=-1; ` + `if [ "\${ac_pmt:-0}" -gt 0 ] && [ -n "\${ac_pma:-}" ]; then ac_pmem=$(( 100 * (ac_pmt - ac_pma) / ac_pmt )); fi; ` + `[ "$ac_pmem" -gt 100 ] && ac_pmem=100; [ "$ac_pmem" -lt -1 ] && ac_pmem=-1; ` + `ac_pdsk=$(df -kP ${disk} 2>/dev/null | awk 'NR==2 && $2>0 {print int(100*$3/$2)}'); ` + `ac_tok="v=1,cpu=$ac_pcpu,l1=\${ac_pl1:--1},mem=$ac_pmem,dsk=\${ac_pdsk:--1},vc=\${ac_pvc:-0},ram=$(( \${ac_pmt:-0} / 1024 )),ts=$(date +%s)"; ` + `}; `;
|
|
3812
|
+
}
|
|
3813
|
+
function perfSamplerFunctionFragment(paths) {
|
|
3814
|
+
const perf = shellQuote(paths.perfPath);
|
|
3815
|
+
const perfTmp = shellQuote(`${paths.perfPath}.tmp`);
|
|
3816
|
+
return perfReadFunctionFragment(paths.procRoot ?? "/proc", paths.diskPath ?? "/") + `ac_perf_tick() { ` + `ac_pbeat=$(( \${ac_pbeat:-0} + 1 )); ` + `[ $(( (ac_pbeat - 1) % ${PERF_SAMPLE_EVERY_BEATS} )) -eq 0 ] || return 0; ` + `ac_perf_read; ` + `[ -n "\${ac_tok:-}" ] || return 0; ` + `printf '%s\\n' "$ac_tok" > ${perfTmp} 2>/dev/null && mv -f ${perfTmp} ${perf} 2>/dev/null; ` + `}; `;
|
|
3817
|
+
}
|
|
3818
|
+
function standalonePerfProbeCommand(opts = {}) {
|
|
3819
|
+
const win = opts.windowSeconds ?? PERF_PROBE_WINDOW_SECONDS;
|
|
3820
|
+
return perfReadFunctionFragment(opts.procRoot ?? "/proc", opts.diskPath ?? "/") + `ac_perf_read; sleep ${win}; ac_perf_read; ` + `printf '${PERF_PROBE_LINE_PREFIX}%s\\n' "\${ac_tok:--}"`;
|
|
3821
|
+
}
|
|
3822
|
+
function parsePerfToken(token) {
|
|
3823
|
+
const t = token.trim();
|
|
3824
|
+
if (t.length === 0 || t.length > PERF_TOKEN_MAX_CHARS)
|
|
3825
|
+
return null;
|
|
3826
|
+
if (!/^v=1(?:,|$)/.test(t))
|
|
3827
|
+
return null;
|
|
3828
|
+
const kv = new Map;
|
|
3829
|
+
for (const part of t.split(",")) {
|
|
3830
|
+
const i = part.indexOf("=");
|
|
3831
|
+
if (i > 0)
|
|
3832
|
+
kv.set(part.slice(0, i), part.slice(i + 1));
|
|
3833
|
+
}
|
|
3834
|
+
const pct = (k) => {
|
|
3835
|
+
const n = Number(kv.get(k));
|
|
3836
|
+
return Number.isFinite(n) && n >= 0 && n <= 100 ? Math.round(n) : null;
|
|
3837
|
+
};
|
|
3838
|
+
const bounded = (k, min, max) => {
|
|
3839
|
+
const n = Number(kv.get(k));
|
|
3840
|
+
return Number.isInteger(n) && n >= min && n <= max ? n : null;
|
|
3841
|
+
};
|
|
3842
|
+
const l1raw = Number(kv.get("l1"));
|
|
3843
|
+
const sample = {
|
|
3844
|
+
cpuBusyPct: pct("cpu"),
|
|
3845
|
+
load1: Number.isFinite(l1raw) && l1raw >= 0 && l1raw <= 1e5 ? l1raw : null,
|
|
3846
|
+
memUsedPct: pct("mem"),
|
|
3847
|
+
diskUsedPct: pct("dsk"),
|
|
3848
|
+
vcpus: bounded("vc", 1, 1024),
|
|
3849
|
+
ramMb: bounded("ram", 1, 16 * 1024 * 1024),
|
|
3850
|
+
sampledAtS: bounded("ts", 1577836800, 4102444800)
|
|
3851
|
+
};
|
|
3852
|
+
if (sample.cpuBusyPct === null && sample.load1 === null && sample.memUsedPct === null && sample.diskUsedPct === null)
|
|
3853
|
+
return null;
|
|
3854
|
+
return sample;
|
|
3855
|
+
}
|
|
3856
|
+
function parsePerfProbeOutput(stdout) {
|
|
3857
|
+
const lines = stdout.split(`
|
|
3858
|
+
`).filter((l) => l.startsWith(PERF_PROBE_LINE_PREFIX));
|
|
3859
|
+
const last = lines[lines.length - 1];
|
|
3860
|
+
if (!last)
|
|
3861
|
+
return null;
|
|
3862
|
+
const token = last.slice(PERF_PROBE_LINE_PREFIX.length).trim();
|
|
3863
|
+
if (token === "-")
|
|
3864
|
+
return null;
|
|
3865
|
+
return parsePerfToken(token);
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3686
3868
|
// src/runtimes/_cli-agent.ts
|
|
3687
3869
|
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
|
3688
3870
|
|
|
@@ -3799,10 +3981,106 @@ function now4() {
|
|
|
3799
3981
|
return new Date().toISOString();
|
|
3800
3982
|
}
|
|
3801
3983
|
var TAIL_REATTACH_DELAY_MS = 2000;
|
|
3984
|
+
var RUNNER_HEARTBEAT_INTERVAL_SECONDS = 10;
|
|
3985
|
+
var RUNNER_BUSY_TASK_FRESH_MINUTES = 2;
|
|
3986
|
+
var RUNNER_BUSY_TASK_ID_LIMIT = 16;
|
|
3987
|
+
function runnerBusySentinelFragment(busyPath, tasksPath) {
|
|
3988
|
+
return `ac_task_files=$(find "$HOME/.claude/tasks" -type f -mmin -${RUNNER_BUSY_TASK_FRESH_MINUTES} 2>/dev/null); ` + `if [ -n "$ac_task_files" ]; then ac_busy=$(printf '%s\\n' "$ac_task_files" | wc -l | tr -cd 0-9); else ac_busy=0; fi; ` + `printf '%s' "$ac_busy" > ${shellQuote(busyPath)} 2>/dev/null; ` + `printf '%s\\n' "$ac_task_files" | sed 's|.*/tasks/||; s|/.*||' | grep . | sort -u ` + `| head -${RUNNER_BUSY_TASK_ID_LIMIT} > ${shellQuote(tasksPath)} 2>/dev/null`;
|
|
3989
|
+
}
|
|
3990
|
+
var RUNNER_HEARTBEAT_STALE_MS = RUNNER_HEARTBEAT_INTERVAL_SECONDS * 1000 * 3;
|
|
3991
|
+
var TAIL_WATCHDOG_INTERVAL_MS = 15000;
|
|
3992
|
+
var TURN_PROBE_TIMEOUT_MS = 1e4;
|
|
3993
|
+
var TAIL_ABANDON_AFTER_STREAM_DEATHS = 3;
|
|
3994
|
+
var DURABLE_POLL_INTERVAL_MS = 1000;
|
|
3995
|
+
var RESUME_TARGET_MISSING_STDERR = /no conversation found with session id/i;
|
|
3996
|
+
function turnLivenessProbeCommand(paths) {
|
|
3997
|
+
return `sz=$(wc -c < ${shellQuote(paths.outPath)} 2>/dev/null || echo -1); ` + `alive=0; kill -0 ${paths.pid} 2>/dev/null && alive=1; ` + `hb=$(cat ${shellQuote(paths.hbPath)} 2>/dev/null || echo 0); ` + `now=$(date +%s%3N); ` + `sent=$(grep -c -F ${shellQuote(paths.sentinel)} ${shellQuote(paths.outPath)} 2>/dev/null || echo 0); ` + `pf=$(head -c ${PERF_TOKEN_MAX_CHARS} ${shellQuote(paths.perfPath)} 2>/dev/null | tr -cd 'A-Za-z0-9=,.-'); ` + `printf '%s %s %s %s %s %s\\n' "$sz" "$alive" "$hb" "$now" "$sent" "\${pf:--}"`;
|
|
3998
|
+
}
|
|
3999
|
+
function parseTurnProbeOutput(raw) {
|
|
4000
|
+
const fields = raw.trim().split(/\s+/);
|
|
4001
|
+
if (fields.length < 5)
|
|
4002
|
+
return null;
|
|
4003
|
+
const size = Number.parseInt(fields[0], 10);
|
|
4004
|
+
const alive = fields[1] === "1" ? true : fields[1] === "0" ? false : null;
|
|
4005
|
+
const hbMs = Number.parseInt(fields[2], 10);
|
|
4006
|
+
const nowMs = Number.parseInt(fields[3], 10);
|
|
4007
|
+
const sentinelCount = Number.parseInt(fields[4], 10);
|
|
4008
|
+
if (!Number.isFinite(size) || alive === null || !Number.isFinite(hbMs) || !Number.isFinite(nowMs) || !Number.isFinite(sentinelCount))
|
|
4009
|
+
return null;
|
|
4010
|
+
const perfTok = fields[5];
|
|
4011
|
+
const perf = perfTok !== undefined && perfTok !== "-" ? parsePerfToken(perfTok) : null;
|
|
4012
|
+
return {
|
|
4013
|
+
size,
|
|
4014
|
+
alive,
|
|
4015
|
+
hbMs,
|
|
4016
|
+
nowMs,
|
|
4017
|
+
sentinelSeen: sentinelCount > 0,
|
|
4018
|
+
...perf ? { perf } : {}
|
|
4019
|
+
};
|
|
4020
|
+
}
|
|
4021
|
+
function livenessVerdictFromReading(reading) {
|
|
4022
|
+
if (reading === null)
|
|
4023
|
+
return "probe-failed";
|
|
4024
|
+
if (reading.alive)
|
|
4025
|
+
return "alive";
|
|
4026
|
+
if (reading.sentinelSeen)
|
|
4027
|
+
return "alive";
|
|
4028
|
+
if (reading.hbMs > 0 && reading.nowMs - reading.hbMs <= RUNNER_HEARTBEAT_STALE_MS)
|
|
4029
|
+
return "alive";
|
|
4030
|
+
return "dead";
|
|
4031
|
+
}
|
|
4032
|
+
var INJECT_FEEDER_POLL_SECONDS = "0.3";
|
|
4033
|
+
var INJECT_FEEDER_RESULT_TAIL_BYTES = 262144;
|
|
4034
|
+
var INJECT_ACK_POLL_ATTEMPTS = 30;
|
|
4035
|
+
var INJECT_ACK_POLL_SECONDS = "0.5";
|
|
4036
|
+
var INJECT_ACK_EXEC_TIMEOUT_MS = 25000;
|
|
4037
|
+
function streamInputFeederFragment(paths) {
|
|
4038
|
+
const q2 = shellQuote;
|
|
4039
|
+
return `rm -f ${q2(paths.fifoPath)}; mkfifo ${q2(paths.fifoPath)}; ` + `: > ${q2(paths.inboxPath)}; echo 0 > ${q2(paths.deliveredPath)}; ` + `( cat ${q2(paths.promptPath)}; ac_fed=0; while kill -0 "$$" 2>/dev/null; do ` + `if tail -c ${INJECT_FEEDER_RESULT_TAIL_BYTES} ${q2(paths.outPath)} 2>/dev/null | grep -q '"type":"result"'; then break; fi; ` + `ac_lines=$(wc -l < ${q2(paths.inboxPath)} 2>/dev/null || echo 0); ac_lines=\${ac_lines:-0}; ` + `if [ "$ac_lines" -gt "$ac_fed" ]; then ` + `sed -n "$((ac_fed+1)),$((ac_lines))p" ${q2(paths.inboxPath)}; ac_fed=$ac_lines; ` + `echo "$ac_fed" > ${q2(paths.deliveredPath)}; fi; ` + `sleep ${INJECT_FEEDER_POLL_SECONDS}; done ) > ${q2(paths.fifoPath)} 2>/dev/null </dev/null & `;
|
|
4040
|
+
}
|
|
4041
|
+
function injectAppendAndAckCommand(args) {
|
|
4042
|
+
const q2 = shellQuote;
|
|
4043
|
+
return `printf '%s\\n' ${q2(args.line)} >> ${q2(args.inboxPath)}; i=0; ` + `while [ "$i" -lt ${INJECT_ACK_POLL_ATTEMPTS} ]; do ` + `d=$(cat ${q2(args.deliveredPath)} 2>/dev/null || echo 0); ` + `[ "\${d:-0}" -ge ${args.target} ] && exit 0; ` + `kill -0 ${args.pid} 2>/dev/null || exit 4; ` + `sleep ${INJECT_ACK_POLL_SECONDS}; i=$((i+1)); done; exit 5`;
|
|
4044
|
+
}
|
|
4045
|
+
var REAP_TERM_WAIT_ATTEMPTS = 20;
|
|
4046
|
+
var REAP_KILL_WAIT_ATTEMPTS = 8;
|
|
4047
|
+
var REAP_POLL_SECONDS = "0.25";
|
|
4048
|
+
var REAP_STILL_ALIVE_EXIT = 7;
|
|
4049
|
+
var REAP_EXEC_TIMEOUT_MS = 15000;
|
|
4050
|
+
function reapKillAndConfirmCommand(pid) {
|
|
4051
|
+
const alive = `kill -0 ${pid} 2>/dev/null`;
|
|
4052
|
+
return `kill -TERM -- -${pid} 2>/dev/null; kill -TERM ${pid} 2>/dev/null; ac_ri=0; ` + `while [ "$ac_ri" -lt ${REAP_TERM_WAIT_ATTEMPTS} ]; do ${alive} || exit 0; ` + `sleep ${REAP_POLL_SECONDS}; ac_ri=$((ac_ri+1)); done; ` + `kill -KILL -- -${pid} 2>/dev/null; kill -KILL ${pid} 2>/dev/null; ac_ri=0; ` + `while [ "$ac_ri" -lt ${REAP_KILL_WAIT_ATTEMPTS} ]; do ${alive} || exit 0; ` + `sleep ${REAP_POLL_SECONDS}; ac_ri=$((ac_ri+1)); done; ` + `exit ${REAP_STILL_ALIVE_EXIT}`;
|
|
4053
|
+
}
|
|
4054
|
+
var RESIDENT_IDLE_TTL_S = 240;
|
|
4055
|
+
var RESIDENT_WATCHDOG_POLL_S = 15;
|
|
4056
|
+
var RESIDENT_RESULT_LINE_PREFIX = '{"type":"result"';
|
|
4057
|
+
function residentResultCountCommand(outPath, fromByte = 0) {
|
|
4058
|
+
const q2 = shellQuote;
|
|
4059
|
+
const src = fromByte > 0 ? `tail -c +${fromByte + 1} ${q2(outPath)} 2>/dev/null` : `cat ${q2(outPath)} 2>/dev/null`;
|
|
4060
|
+
return `ac_res=$(${src} | grep -c ${q2(`^${RESIDENT_RESULT_LINE_PREFIX}`)}); echo "\${ac_res:-0}"`;
|
|
4061
|
+
}
|
|
4062
|
+
function residentFeederFragment(paths) {
|
|
4063
|
+
const q2 = shellQuote;
|
|
4064
|
+
return `rm -f ${q2(paths.fifoPath)}; mkfifo ${q2(paths.fifoPath)}; ` + `: > ${q2(paths.inboxPath)}; echo 0 > ${q2(paths.deliveredPath)}; rm -f ${q2(paths.endPath)}; ` + `( cat ${q2(paths.promptPath)}; ac_fed=0; while kill -0 "$$" 2>/dev/null; do ` + `if [ -f ${q2(paths.endPath)} ]; then break; fi; ` + `ac_lines=$(wc -l < ${q2(paths.inboxPath)} 2>/dev/null || echo 0); ac_lines=\${ac_lines:-0}; ` + `if [ "$ac_lines" -gt "$ac_fed" ]; then ` + `sed -n "$((ac_fed+1)),$((ac_lines))p" ${q2(paths.inboxPath)}; ac_fed=$ac_lines; ` + `echo "$ac_fed" > ${q2(paths.deliveredPath)}; fi; ` + `sleep ${INJECT_FEEDER_POLL_SECONDS}; done ) > ${q2(paths.fifoPath)} 2>/dev/null </dev/null & `;
|
|
4065
|
+
}
|
|
4066
|
+
function residentIdleCheckFragment(paths) {
|
|
4067
|
+
const q2 = shellQuote;
|
|
4068
|
+
return `ac_res=$(grep -c ${q2(`^${RESIDENT_RESULT_LINE_PREFIX}`)} ${q2(paths.outPath)} 2>/dev/null); ac_res=\${ac_res:-0}; ` + `ac_srv=$(cat ${q2(paths.servedPath)} 2>/dev/null); ac_srv=\${ac_srv:-1}; ` + `ac_in=$(wc -l < ${q2(paths.inboxPath)} 2>/dev/null); ac_in=\${ac_in:-0}; ` + `ac_del=$(cat ${q2(paths.deliveredPath)} 2>/dev/null); ac_del=\${ac_del:-0}; ` + `if [ "$ac_res" -ge "$ac_srv" ] && [ "$ac_in" -eq "$ac_del" ]; then ac_idle=1; else ac_idle=0; fi; `;
|
|
4069
|
+
}
|
|
4070
|
+
function residentIdleWatchdogFragment(paths) {
|
|
4071
|
+
return `( ac_idle_s=0; while kill -0 "$$" 2>/dev/null; do ` + residentIdleCheckFragment(paths) + `if [ "$ac_idle" = 1 ] && find "$HOME/.claude/tasks" -type f -mmin -${RUNNER_BUSY_TASK_FRESH_MINUTES} 2>/dev/null | grep -q .; then ac_idle=0; fi; ` + `if [ "$ac_idle" = 1 ]; then ac_idle_s=$((ac_idle_s+${RESIDENT_WATCHDOG_POLL_S})); else ac_idle_s=0; fi; ` + `if [ "$ac_idle_s" -ge ${RESIDENT_IDLE_TTL_S} ]; then kill -- -$$ 2>/dev/null; exit 0; fi; ` + `sleep ${RESIDENT_WATCHDOG_POLL_S}; done ) >/dev/null 2>&1 </dev/null & `;
|
|
4072
|
+
}
|
|
4073
|
+
function deferredExitEventPushCommand(notify, turnIdFile) {
|
|
4074
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(notify.tokenEnv))
|
|
4075
|
+
return "";
|
|
4076
|
+
const q2 = shellQuote;
|
|
4077
|
+
return `( ac_tid=$(cat ${q2(turnIdFile)} 2>/dev/null | tr -cd 'A-Za-z0-9-'); ` + `if [ -n "$ac_tid" ] && [ -n "$${notify.tokenEnv}" ] && command -v curl >/dev/null 2>&1; then ` + `curl -fsS -m ${EXIT_PUSH_ATTEMPT_TIMEOUT_SECONDS} -o /dev/null -X POST ` + `-H "Authorization: Bearer $${notify.tokenEnv}" ` + `-H 'Content-Type: application/json' --data "{\\"turnId\\":\\"$ac_tid\\",\\"exitCode\\":$ac_rc}" ${q2(notify.url)}; ` + `fi ) >/dev/null 2>&1 || true`;
|
|
4078
|
+
}
|
|
3802
4079
|
var LAUNCH_EXEC_TIMEOUT_MS = 30000;
|
|
3803
4080
|
var LAUNCH_PID_RECOVERY_ATTEMPTS = 3;
|
|
3804
4081
|
var LAUNCH_PID_RECOVERY_DELAY_MS = 1000;
|
|
3805
4082
|
var INSTALL_PROBE_TIMEOUT_MS = 30000;
|
|
4083
|
+
var CLI_INSTALL_TIMEOUT_MS = 300000;
|
|
3806
4084
|
function parsePid(raw) {
|
|
3807
4085
|
const pid = Number.parseInt(raw.trim().split(`
|
|
3808
4086
|
`).pop() ?? "", 10);
|
|
@@ -3824,6 +4102,30 @@ async function recoverLaunchPid(read, pidPath) {
|
|
|
3824
4102
|
function shellQuote(value) {
|
|
3825
4103
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
3826
4104
|
}
|
|
4105
|
+
var SESSION_ENV_FILE_RELPATH = ".agent-compose/session-env.sh";
|
|
4106
|
+
var SESSION_ENV_FILE_SAFE = /^[A-Za-z0-9._\/-]+$/;
|
|
4107
|
+
function sessionEnvSourceFragment(relPath) {
|
|
4108
|
+
if (!relPath || !SESSION_ENV_FILE_SAFE.test(relPath) || relPath.includes(".."))
|
|
4109
|
+
return "";
|
|
4110
|
+
return `[ -f "$HOME/${relPath}" ] && . "$HOME/${relPath}" >/dev/null 2>&1 || true; `;
|
|
4111
|
+
}
|
|
4112
|
+
function credEnvSourceFragment(absPath) {
|
|
4113
|
+
if (!absPath || !absPath.startsWith("/") || !SESSION_ENV_FILE_SAFE.test(absPath.slice(1)) || absPath.includes(".."))
|
|
4114
|
+
return "";
|
|
4115
|
+
return `[ -f "${absPath}" ] && . "${absPath}" >/dev/null 2>&1 || true; `;
|
|
4116
|
+
}
|
|
4117
|
+
var EXIT_PUSH_ATTEMPT_TIMEOUT_SECONDS = 5;
|
|
4118
|
+
var EXIT_PUSH_RETRY_DELAY_SECONDS = 1;
|
|
4119
|
+
function exitEventPushCommand(notify) {
|
|
4120
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(notify.tokenEnv))
|
|
4121
|
+
return "";
|
|
4122
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(notify.turnId))
|
|
4123
|
+
return "";
|
|
4124
|
+
const token = `"$${notify.tokenEnv}"`;
|
|
4125
|
+
const body = `"{\\"turnId\\":\\"${notify.turnId}\\",\\"exitCode\\":$ac_rc}"`;
|
|
4126
|
+
const attempt = `curl -fsS -m ${EXIT_PUSH_ATTEMPT_TIMEOUT_SECONDS} -o /dev/null -X POST` + ` -H "Authorization: Bearer $${notify.tokenEnv}"` + ` -H 'Content-Type: application/json' --data ${body} ${shellQuote(notify.url)}`;
|
|
4127
|
+
return `( if [ -n ${token} ] && command -v curl >/dev/null 2>&1; then ` + `${attempt} || { sleep ${EXIT_PUSH_RETRY_DELAY_SECONDS}; ${attempt}; }; ` + `fi ) >/dev/null 2>&1 || true`;
|
|
4128
|
+
}
|
|
3827
4129
|
var promptFileCounter = 0;
|
|
3828
4130
|
function uniquePromptPath(kind, agentId, iteration) {
|
|
3829
4131
|
const unique = `${Date.now().toString(36)}-${(promptFileCounter++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -3863,6 +4165,60 @@ class CliAgentRunner {
|
|
|
3863
4165
|
acpSession;
|
|
3864
4166
|
currentAcpProcCtx;
|
|
3865
4167
|
acpTransportReady = ACP_TRANSPORT_READY;
|
|
4168
|
+
turnProbe = null;
|
|
4169
|
+
turnInject = null;
|
|
4170
|
+
async injectUserMessage(text) {
|
|
4171
|
+
const inject = this.turnInject;
|
|
4172
|
+
const streamInput = this.spec.streamInput;
|
|
4173
|
+
if (!inject || !streamInput || this.guestDetached)
|
|
4174
|
+
return "unsupported";
|
|
4175
|
+
const line = streamInput.messageLine(text);
|
|
4176
|
+
const attempt = inject.chain.then(async () => {
|
|
4177
|
+
if (this.turnInject !== inject)
|
|
4178
|
+
return "closed";
|
|
4179
|
+
const target = inject.appended + 1;
|
|
4180
|
+
inject.appended = target;
|
|
4181
|
+
try {
|
|
4182
|
+
const res = await this.sandbox.commands.run(injectAppendAndAckCommand({
|
|
4183
|
+
line,
|
|
4184
|
+
target,
|
|
4185
|
+
pid: inject.pid,
|
|
4186
|
+
inboxPath: inject.inboxPath,
|
|
4187
|
+
deliveredPath: inject.deliveredPath
|
|
4188
|
+
}), { timeoutMs: INJECT_ACK_EXEC_TIMEOUT_MS });
|
|
4189
|
+
if (res.exitCode === 0)
|
|
4190
|
+
return "delivered";
|
|
4191
|
+
return res.exitCode === 5 ? "pending" : "closed";
|
|
4192
|
+
} catch {
|
|
4193
|
+
return "closed";
|
|
4194
|
+
}
|
|
4195
|
+
});
|
|
4196
|
+
inject.chain = attempt.catch(() => {
|
|
4197
|
+
return;
|
|
4198
|
+
});
|
|
4199
|
+
return attempt;
|
|
4200
|
+
}
|
|
4201
|
+
async probeTurnLiveness() {
|
|
4202
|
+
const probe = this.turnProbe;
|
|
4203
|
+
if (!probe)
|
|
4204
|
+
return null;
|
|
4205
|
+
return livenessVerdictFromReading(await probe());
|
|
4206
|
+
}
|
|
4207
|
+
wakeTailWatchdog = null;
|
|
4208
|
+
nudgePending = false;
|
|
4209
|
+
nudgeTurnProbe() {
|
|
4210
|
+
const wake = this.wakeTailWatchdog;
|
|
4211
|
+
if (wake) {
|
|
4212
|
+
this.wakeTailWatchdog = null;
|
|
4213
|
+
wake();
|
|
4214
|
+
} else {
|
|
4215
|
+
this.nudgePending = true;
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
guestDetached = false;
|
|
4219
|
+
detachGuest() {
|
|
4220
|
+
this.guestDetached = true;
|
|
4221
|
+
}
|
|
3866
4222
|
constructor(sandbox, options, spec, configModel, configEffort) {
|
|
3867
4223
|
this.sandbox = sandbox;
|
|
3868
4224
|
this.options = options;
|
|
@@ -3911,7 +4267,7 @@ class CliAgentRunner {
|
|
|
3911
4267
|
this.installed = true;
|
|
3912
4268
|
return;
|
|
3913
4269
|
}
|
|
3914
|
-
const res = await this.sandbox.commands.run(this.spec.install, { timeoutMs:
|
|
4270
|
+
const res = await this.sandbox.commands.run(this.spec.install, { timeoutMs: CLI_INSTALL_TIMEOUT_MS });
|
|
3915
4271
|
if (res.exitCode !== 0) {
|
|
3916
4272
|
throw new Error(`failed to install ${this.spec.bin}: ${(res.stderr || res.stdout || "").slice(-500)}`);
|
|
3917
4273
|
}
|
|
@@ -4052,17 +4408,25 @@ class CliAgentRunner {
|
|
|
4052
4408
|
return;
|
|
4053
4409
|
await this.ensureInstalled();
|
|
4054
4410
|
const promptPath = uniquePromptPath(this.spec.kind, this.options.agentId ?? "agent", opts.iteration ?? 0);
|
|
4055
|
-
|
|
4411
|
+
this.turnProbe = null;
|
|
4412
|
+
this.turnInject = null;
|
|
4413
|
+
this.nudgePending = false;
|
|
4414
|
+
this.wakeTailWatchdog = null;
|
|
4415
|
+
const streamInput = this.spec.streamInput !== undefined && this.sandbox.commands.runBackground !== undefined;
|
|
4416
|
+
const fifoPath = `${promptPath}.fifo`;
|
|
4417
|
+
const promptWrite = this.sandbox.files.write(promptPath, streamInput ? `${this.spec.streamInput.promptLine(opts.prompt)}
|
|
4418
|
+
` : this.spec.promptPayload(opts.prompt));
|
|
4056
4419
|
const cmd = this.spec.buildCommand({
|
|
4057
|
-
promptPath,
|
|
4420
|
+
promptPath: streamInput ? fifoPath : promptPath,
|
|
4058
4421
|
sessionId: opts.sessionId,
|
|
4059
4422
|
model: this.model,
|
|
4060
4423
|
cwd: this.options.cwd,
|
|
4061
|
-
effort: this.configEffort
|
|
4424
|
+
effort: this.configEffort,
|
|
4425
|
+
...streamInput ? { streamInput: true } : {}
|
|
4062
4426
|
});
|
|
4063
4427
|
const guardPath = `${promptPath}.guard.js`;
|
|
4064
4428
|
const sentinel = `__AC_JSONL_EXIT_${Math.random().toString(36).slice(2, 10)}__`;
|
|
4065
|
-
await this.sandbox.files.write(guardPath, jsonlGuardScript());
|
|
4429
|
+
await Promise.all([promptWrite, this.sandbox.files.write(guardPath, jsonlGuardScript())]);
|
|
4066
4430
|
const guardedCmd = wrapJsonlCommand({
|
|
4067
4431
|
cmd,
|
|
4068
4432
|
guardPath,
|
|
@@ -4076,14 +4440,23 @@ class CliAgentRunner {
|
|
|
4076
4440
|
let transportError = null;
|
|
4077
4441
|
let consumerStopped = false;
|
|
4078
4442
|
let reap = () => {};
|
|
4443
|
+
let reapConfirm = null;
|
|
4079
4444
|
let transport;
|
|
4080
4445
|
const durableRead = this.sandbox.files.read?.bind(this.sandbox.files);
|
|
4081
4446
|
if (this.sandbox.commands.runBackground) {
|
|
4082
4447
|
const outPath = `${promptPath}.out`;
|
|
4083
4448
|
const errPath = `${promptPath}.err`;
|
|
4084
|
-
const
|
|
4449
|
+
const hbPath = `${promptPath}.hb`;
|
|
4450
|
+
const exitPush = this.options.turnExitNotify ? exitEventPushCommand(this.options.turnExitNotify) : "";
|
|
4451
|
+
const busyPath = `${promptPath}.busy`;
|
|
4452
|
+
const tasksPath = `${promptPath}.tasks`;
|
|
4453
|
+
const inboxPath = `${promptPath}.inbox`;
|
|
4454
|
+
const deliveredPath = `${promptPath}.delivered`;
|
|
4455
|
+
const feeder = streamInput ? streamInputFeederFragment({ promptPath, fifoPath, inboxPath, deliveredPath, outPath }) : "";
|
|
4456
|
+
const perfPath = `${promptPath}.perf`;
|
|
4457
|
+
const detachedScript = feeder + `( ${perfSamplerFunctionFragment({ perfPath })}while kill -0 "$$" 2>/dev/null; do ` + `date +%s%3N > ${shellQuote(hbPath)}; ` + `${runnerBusySentinelFragment(busyPath, tasksPath)}; ` + `ac_perf_tick; ` + `sleep ${RUNNER_HEARTBEAT_INTERVAL_SECONDS}; done ) >/dev/null 2>&1 </dev/null & ` + sessionEnvSourceFragment(this.options.sessionEnvFile) + credEnvSourceFragment(this.options.credEnvFile) + `{ ${guardedCmd}; } >> ${shellQuote(outPath)} 2>> ${shellQuote(errPath)} </dev/null; ac_rc=$?; ` + `printf '\\n%s %s\\n' ${shellQuote(sentinel)} "$ac_rc" >> ${shellQuote(outPath)}` + (exitPush ? `; ${exitPush}` : "");
|
|
4085
4458
|
const pidPath = `${promptPath}.pid`;
|
|
4086
|
-
const launchCmd = `: > ${shellQuote(outPath)}; : > ${shellQuote(errPath)}; ` + `if command -v setsid >/dev/null 2>&1; then setsid sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & ` + `else sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & fi; ` + `echo "$!" > ${shellQuote(pidPath)}; echo "$!"`;
|
|
4459
|
+
const launchCmd = `: > ${shellQuote(outPath)}; : > ${shellQuote(errPath)}; date +%s%3N > ${shellQuote(hbPath)}; ` + `if command -v setsid >/dev/null 2>&1; then setsid sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & ` + `else sh -c ${shellQuote(detachedScript)} >/dev/null 2>&1 </dev/null & fi; ` + `echo "$!" > ${shellQuote(pidPath)}; echo "$!"`;
|
|
4087
4460
|
let pid = Number.NaN;
|
|
4088
4461
|
let launched = null;
|
|
4089
4462
|
let launchErr = null;
|
|
@@ -4106,6 +4479,19 @@ class CliAgentRunner {
|
|
|
4106
4479
|
throw launchErr;
|
|
4107
4480
|
console.warn(`[cli-agent] ${this.spec.kind} launch exec stream died but the detached runner is up ` + `(pid ${pid} via ${pidPath}) — continuing on the durable transport: ${formatError(launchErr)}`);
|
|
4108
4481
|
}
|
|
4482
|
+
const probeCmd = turnLivenessProbeCommand({ outPath, hbPath, perfPath, pid, sentinel });
|
|
4483
|
+
const runTurnProbe = async () => {
|
|
4484
|
+
try {
|
|
4485
|
+
const res = await this.sandbox.commands.run(probeCmd, { timeoutMs: TURN_PROBE_TIMEOUT_MS });
|
|
4486
|
+
return parseTurnProbeOutput(res.stdout ?? "");
|
|
4487
|
+
} catch {
|
|
4488
|
+
return null;
|
|
4489
|
+
}
|
|
4490
|
+
};
|
|
4491
|
+
this.turnProbe = runTurnProbe;
|
|
4492
|
+
if (streamInput) {
|
|
4493
|
+
this.turnInject = { inboxPath, deliveredPath, pid, appended: 0, chain: Promise.resolve() };
|
|
4494
|
+
}
|
|
4109
4495
|
let offset = 0;
|
|
4110
4496
|
let currentTail = null;
|
|
4111
4497
|
const consumeLine = (raw) => {
|
|
@@ -4121,10 +4507,15 @@ class CliAgentRunner {
|
|
|
4121
4507
|
lines.push(line);
|
|
4122
4508
|
};
|
|
4123
4509
|
reap = () => {
|
|
4124
|
-
this.sandbox.commands.run(
|
|
4510
|
+
reapConfirm ??= this.sandbox.commands.run(reapKillAndConfirmCommand(pid), { timeoutMs: REAP_EXEC_TIMEOUT_MS }).then((res) => {
|
|
4511
|
+
if (res.exitCode === REAP_STILL_ALIVE_EXIT) {
|
|
4512
|
+
console.warn(`[cli-agent] ${this.spec.kind} runner ${pid} survived SIGKILL's confirm window ` + "— the next launch's preflight is the backstop");
|
|
4513
|
+
}
|
|
4514
|
+
}).catch(() => {});
|
|
4125
4515
|
currentTail?.kill().catch(() => {});
|
|
4126
4516
|
};
|
|
4127
4517
|
transport = (async () => {
|
|
4518
|
+
let streamDeaths = 0;
|
|
4128
4519
|
while (exitCode === null && !consumerStopped && !opts.signal?.aborted) {
|
|
4129
4520
|
let buf = "";
|
|
4130
4521
|
let handle = null;
|
|
@@ -4141,16 +4532,68 @@ class CliAgentRunner {
|
|
|
4141
4532
|
}
|
|
4142
4533
|
}
|
|
4143
4534
|
};
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4535
|
+
if (streamDeaths < TAIL_ABANDON_AFTER_STREAM_DEATHS) {
|
|
4536
|
+
try {
|
|
4537
|
+
handle = await this.sandbox.commands.runBackground(`tail -s 0.05 -c +${offset + 1} -f ${shellQuote(outPath)}`, { timeoutMs: 0, onStdout });
|
|
4538
|
+
} catch {}
|
|
4539
|
+
}
|
|
4147
4540
|
if (handle) {
|
|
4148
4541
|
if (exitCode !== null)
|
|
4149
4542
|
handle.kill().catch(() => {});
|
|
4150
4543
|
currentTail = handle;
|
|
4151
|
-
await
|
|
4152
|
-
|
|
4153
|
-
|
|
4544
|
+
await (async (tail) => {
|
|
4545
|
+
let waitSettled = false;
|
|
4546
|
+
const waitP = tail.wait().catch(() => {
|
|
4547
|
+
return;
|
|
4548
|
+
}).then(() => {
|
|
4549
|
+
waitSettled = true;
|
|
4550
|
+
});
|
|
4551
|
+
let lastTickOffset = -1;
|
|
4552
|
+
while (!waitSettled) {
|
|
4553
|
+
let onAbort;
|
|
4554
|
+
const wake = new Promise((resolve) => {
|
|
4555
|
+
const t = setTimeout(resolve, TAIL_WATCHDOG_INTERVAL_MS);
|
|
4556
|
+
t.unref?.();
|
|
4557
|
+
const fire = () => {
|
|
4558
|
+
clearTimeout(t);
|
|
4559
|
+
resolve();
|
|
4560
|
+
};
|
|
4561
|
+
if (this.nudgePending) {
|
|
4562
|
+
this.nudgePending = false;
|
|
4563
|
+
fire();
|
|
4564
|
+
} else {
|
|
4565
|
+
this.wakeTailWatchdog = fire;
|
|
4566
|
+
}
|
|
4567
|
+
if (opts.signal) {
|
|
4568
|
+
onAbort = fire;
|
|
4569
|
+
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
4570
|
+
}
|
|
4571
|
+
});
|
|
4572
|
+
await Promise.race([waitP, wake]);
|
|
4573
|
+
this.wakeTailWatchdog = null;
|
|
4574
|
+
if (onAbort)
|
|
4575
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
4576
|
+
if (waitSettled || exitCode !== null || consumerStopped || opts.signal?.aborted)
|
|
4577
|
+
return;
|
|
4578
|
+
const tickOffset = offset;
|
|
4579
|
+
const reading = await runTurnProbe();
|
|
4580
|
+
if (offset !== tickOffset) {
|
|
4581
|
+
lastTickOffset = offset;
|
|
4582
|
+
continue;
|
|
4583
|
+
}
|
|
4584
|
+
if (reading === null) {
|
|
4585
|
+
lastTickOffset = tickOffset;
|
|
4586
|
+
continue;
|
|
4587
|
+
}
|
|
4588
|
+
const streamStarved = reading.size > offset && tickOffset === lastTickOffset;
|
|
4589
|
+
if (reading.sentinelSeen || !reading.alive || streamStarved) {
|
|
4590
|
+
console.warn(`[cli-agent] ${this.spec.kind} tail stream ${reading.sentinelSeen ? "outlived the FINISHED turn (durable sentinel unharvested)" : !reading.alive ? "outlived the runner" : `starved (durable file at ${reading.size} bytes, stream stuck at ${offset})`}` + " — killing the tail and catching up from the durable file");
|
|
4591
|
+
tail.kill().catch(() => {});
|
|
4592
|
+
return;
|
|
4593
|
+
}
|
|
4594
|
+
lastTickOffset = tickOffset;
|
|
4595
|
+
}
|
|
4596
|
+
})(handle);
|
|
4154
4597
|
currentTail = null;
|
|
4155
4598
|
}
|
|
4156
4599
|
if (exitCode !== null || consumerStopped || opts.signal?.aborted)
|
|
@@ -4180,8 +4623,16 @@ class CliAgentRunner {
|
|
|
4180
4623
|
exitCode = JSONL_GUARD_NO_SENTINEL_EXIT;
|
|
4181
4624
|
break;
|
|
4182
4625
|
}
|
|
4183
|
-
|
|
4184
|
-
|
|
4626
|
+
streamDeaths += 1;
|
|
4627
|
+
if (streamDeaths < TAIL_ABANDON_AFTER_STREAM_DEATHS) {
|
|
4628
|
+
console.warn(`[cli-agent] ${this.spec.kind} turn stream lost with the runner alive — re-tailing ${outPath} from byte ${offset}`);
|
|
4629
|
+
await new Promise((r) => setTimeout(r, TAIL_REATTACH_DELAY_MS));
|
|
4630
|
+
} else {
|
|
4631
|
+
if (streamDeaths === TAIL_ABANDON_AFTER_STREAM_DEATHS) {
|
|
4632
|
+
console.warn(`[cli-agent] ${this.spec.kind} turn abandoning streaming after ` + `${streamDeaths} stream deaths — finishing on durable polling from byte ${offset}`);
|
|
4633
|
+
}
|
|
4634
|
+
await new Promise((r) => setTimeout(r, DURABLE_POLL_INTERVAL_MS));
|
|
4635
|
+
}
|
|
4185
4636
|
}
|
|
4186
4637
|
if (exitCode !== null && exitCode !== 0 && durableRead) {
|
|
4187
4638
|
try {
|
|
@@ -4207,7 +4658,7 @@ class CliAgentRunner {
|
|
|
4207
4658
|
onStdout,
|
|
4208
4659
|
timeoutMs: 0
|
|
4209
4660
|
};
|
|
4210
|
-
transport = this.sandbox.commands.run(guardedCmd, runOpts).then((res) => {
|
|
4661
|
+
transport = this.sandbox.commands.run(credEnvSourceFragment(this.options.credEnvFile) + guardedCmd, runOpts).then((res) => {
|
|
4211
4662
|
const tail = buf.trim();
|
|
4212
4663
|
if (tail)
|
|
4213
4664
|
lines.push(tail);
|
|
@@ -4218,13 +4669,18 @@ class CliAgentRunner {
|
|
|
4218
4669
|
}).finally(() => lines.close());
|
|
4219
4670
|
}
|
|
4220
4671
|
transport.catch(() => {});
|
|
4672
|
+
const reapUnlessDetached = () => {
|
|
4673
|
+
if (!this.guestDetached)
|
|
4674
|
+
reap();
|
|
4675
|
+
};
|
|
4221
4676
|
if (opts.signal) {
|
|
4222
4677
|
if (opts.signal.aborted)
|
|
4223
|
-
|
|
4678
|
+
reapUnlessDetached();
|
|
4224
4679
|
else
|
|
4225
|
-
opts.signal.addEventListener("abort",
|
|
4680
|
+
opts.signal.addEventListener("abort", reapUnlessDetached, { once: true });
|
|
4226
4681
|
}
|
|
4227
4682
|
try {
|
|
4683
|
+
let announcedSessionId = opts.sessionId ?? "";
|
|
4228
4684
|
for await (const line of lines) {
|
|
4229
4685
|
let parsed;
|
|
4230
4686
|
try {
|
|
@@ -4235,6 +4691,10 @@ class CliAgentRunner {
|
|
|
4235
4691
|
const sid = this.spec.extractSessionId(parsed);
|
|
4236
4692
|
if (sid)
|
|
4237
4693
|
sessionId = sid;
|
|
4694
|
+
if (sid && sid !== announcedSessionId) {
|
|
4695
|
+
announcedSessionId = sid;
|
|
4696
|
+
yield { type: "init", sessionId: sid, timestamp: now4() };
|
|
4697
|
+
}
|
|
4238
4698
|
for (const msg of this.spec.mapEvent(parsed)) {
|
|
4239
4699
|
if (msg.type === "error")
|
|
4240
4700
|
sawError = true;
|
|
@@ -4255,14 +4715,23 @@ class CliAgentRunner {
|
|
|
4255
4715
|
yield { type: "error", text: `${this.spec.kind} exited with code ${exitCode}${detail}${exitStderr ? `: ${exitStderr}` : ""}`, timestamp: now4() };
|
|
4256
4716
|
return;
|
|
4257
4717
|
}
|
|
4718
|
+
if (exitCode !== 0 && sawError && RESUME_TARGET_MISSING_STDERR.test(exitStderr)) {
|
|
4719
|
+
yield { type: "error", text: `${this.spec.kind} exited with code ${exitCode}: ${exitStderr}`, timestamp: now4() };
|
|
4720
|
+
return;
|
|
4721
|
+
}
|
|
4258
4722
|
if (!sawError)
|
|
4259
4723
|
yield { type: "done", sessionId: sessionId ?? "", timestamp: now4() };
|
|
4260
4724
|
} finally {
|
|
4261
|
-
opts.signal?.removeEventListener("abort",
|
|
4725
|
+
opts.signal?.removeEventListener("abort", reapUnlessDetached);
|
|
4262
4726
|
consumerStopped = true;
|
|
4263
|
-
if (exitCode === null)
|
|
4727
|
+
if (exitCode === null && !this.guestDetached)
|
|
4264
4728
|
reap();
|
|
4265
|
-
|
|
4729
|
+
if (reapConfirm)
|
|
4730
|
+
await reapConfirm;
|
|
4731
|
+
this.turnInject = null;
|
|
4732
|
+
if (!this.guestDetached) {
|
|
4733
|
+
this.sandbox.commands.run(`rm -f ${shellQuote(promptPath)} ${shellQuote(guardPath)} ${shellQuote(`${promptPath}.out`)} ${shellQuote(`${promptPath}.err`)} ${shellQuote(`${promptPath}.pid`)} ${shellQuote(`${promptPath}.hb`)} ${shellQuote(`${promptPath}.busy`)} ${shellQuote(`${promptPath}.tasks`)} ${shellQuote(`${promptPath}.fifo`)} ${shellQuote(`${promptPath}.inbox`)} ${shellQuote(`${promptPath}.delivered`)}`, { timeoutMs: 1e4 }).catch(() => {});
|
|
4734
|
+
}
|
|
4266
4735
|
}
|
|
4267
4736
|
} catch (err) {
|
|
4268
4737
|
yield { type: "error", text: formatError(err), timestamp: now4() };
|
|
@@ -4286,10 +4755,19 @@ var CODEX_ADVISORY_PATTERNS = [
|
|
|
4286
4755
|
function isCodexAdvisoryNoise(text) {
|
|
4287
4756
|
return CODEX_ADVISORY_PATTERNS.some((re) => re.test(text));
|
|
4288
4757
|
}
|
|
4758
|
+
var CODEX_LOCK_WAIT_ATTEMPTS = 20;
|
|
4759
|
+
var CODEX_LOCK_WAIT_SECONDS = "0.25";
|
|
4760
|
+
var CODEX_THREAD_ID_SAFE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
4761
|
+
function codexWriterLockPreflight(threadId, waitAttempts = CODEX_LOCK_WAIT_ATTEMPTS) {
|
|
4762
|
+
if (!CODEX_THREAD_ID_SAFE.test(threadId))
|
|
4763
|
+
return "";
|
|
4764
|
+
return `ac_lk="\${CODEX_HOME:-$HOME/.codex}/thread-writer-locks/${threadId}.lock"; ` + `if [ -e "$ac_lk" ] && command -v flock >/dev/null 2>&1; then ac_lki=0; ` + `while ! flock -n "$ac_lk" true 2>/dev/null && [ "$ac_lki" -lt ${waitAttempts} ]; do ` + `sleep ${CODEX_LOCK_WAIT_SECONDS}; ac_lki=$((ac_lki+1)); done; ` + `flock -n "$ac_lk" rm -f -- "$ac_lk" 2>/dev/null || true; fi; `;
|
|
4765
|
+
}
|
|
4289
4766
|
var codexSpec = {
|
|
4290
4767
|
kind: "codex",
|
|
4291
4768
|
authEnv: "CODEX_API_KEY",
|
|
4292
4769
|
bin: "codex",
|
|
4770
|
+
exclusiveSessionWriter: true,
|
|
4293
4771
|
acp: {
|
|
4294
4772
|
command: "npx",
|
|
4295
4773
|
args: ["-y", CODEX_ACP_ADAPTER],
|
|
@@ -4306,8 +4784,9 @@ var codexSpec = {
|
|
|
4306
4784
|
...effort ? ["-c", `model_reasoning_effort=${effort === "max" ? "xhigh" : effort}`] : []
|
|
4307
4785
|
].join(" ");
|
|
4308
4786
|
const cd = cwd ? `cd ${shellQuote(cwd)} && ` : "";
|
|
4787
|
+
const preflight = sessionId ? codexWriterLockPreflight(sessionId) : "";
|
|
4309
4788
|
const exec = sessionId ? `codex exec resume ${shellQuote(sessionId)} ${flags}` : `codex exec ${flags}`;
|
|
4310
|
-
return `${cd}${exec} - < ${shellQuote(promptPath)}`;
|
|
4789
|
+
return `${preflight}${cd}${exec} - < ${shellQuote(promptPath)}`;
|
|
4311
4790
|
},
|
|
4312
4791
|
extractSessionId: (p) => p.type === "thread.started" && typeof p.thread_id === "string" ? p.thread_id : undefined,
|
|
4313
4792
|
mapEvent: (p) => {
|
|
@@ -4525,6 +5004,84 @@ function toolResultText(content) {
|
|
|
4525
5004
|
}
|
|
4526
5005
|
return content == null ? "" : JSON.stringify(content) ?? "";
|
|
4527
5006
|
}
|
|
5007
|
+
var TASK_NOTIFICATION_RE = /<task-notification>([\s\S]*?)<\/task-notification>/g;
|
|
5008
|
+
var NOTIFICATION_SUMMARY_MAX = 500;
|
|
5009
|
+
var NOTIFICATION_REPORT_MAX = 20000;
|
|
5010
|
+
function innerTag(body, tag) {
|
|
5011
|
+
const m = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(body);
|
|
5012
|
+
const text = m?.[1]?.trim() ?? "";
|
|
5013
|
+
return text.length > 0 ? text : null;
|
|
5014
|
+
}
|
|
5015
|
+
function unescapeEntities(s) {
|
|
5016
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
5017
|
+
}
|
|
5018
|
+
var clip = (s, max) => s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
|
5019
|
+
function intTag(body, tag) {
|
|
5020
|
+
const raw = innerTag(body, tag);
|
|
5021
|
+
if (!raw)
|
|
5022
|
+
return;
|
|
5023
|
+
const n = Number.parseInt(raw, 10);
|
|
5024
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
5025
|
+
}
|
|
5026
|
+
function parseTaskNotifications(text, timestamp) {
|
|
5027
|
+
if (!text.includes("<task-notification>"))
|
|
5028
|
+
return [];
|
|
5029
|
+
const out = [];
|
|
5030
|
+
TASK_NOTIFICATION_RE.lastIndex = 0;
|
|
5031
|
+
for (let m = TASK_NOTIFICATION_RE.exec(text);m !== null; m = TASK_NOTIFICATION_RE.exec(text)) {
|
|
5032
|
+
const body = m[1] ?? "";
|
|
5033
|
+
const taskId = innerTag(body, "task-id");
|
|
5034
|
+
if (!taskId)
|
|
5035
|
+
continue;
|
|
5036
|
+
const toolUseId = innerTag(body, "tool-use-id");
|
|
5037
|
+
const summary = innerTag(body, "summary");
|
|
5038
|
+
const report = innerTag(body, "result");
|
|
5039
|
+
const usageBody = /<usage>([\s\S]*?)<\/usage>/.exec(body)?.[1] ?? "";
|
|
5040
|
+
const tokens = intTag(usageBody, "subagent_tokens");
|
|
5041
|
+
const toolUses = intTag(usageBody, "tool_uses");
|
|
5042
|
+
const durationMs = intTag(usageBody, "duration_ms");
|
|
5043
|
+
out.push({
|
|
5044
|
+
type: "task_notification",
|
|
5045
|
+
taskId: clip(taskId, 128),
|
|
5046
|
+
...toolUseId ? { toolUseId: clip(toolUseId, 128) } : {},
|
|
5047
|
+
status: innerTag(body, "status") ?? "finished",
|
|
5048
|
+
summary: summary ? clip(unescapeEntities(summary.replace(/\s+/g, " ")), NOTIFICATION_SUMMARY_MAX) : "",
|
|
5049
|
+
...report ? { report: clip(unescapeEntities(report), NOTIFICATION_REPORT_MAX) } : {},
|
|
5050
|
+
...tokens !== undefined || toolUses !== undefined || durationMs !== undefined ? { usage: {
|
|
5051
|
+
...tokens !== undefined ? { tokens } : {},
|
|
5052
|
+
...toolUses !== undefined ? { toolUses } : {},
|
|
5053
|
+
...durationMs !== undefined ? { durationMs } : {}
|
|
5054
|
+
} } : {},
|
|
5055
|
+
timestamp
|
|
5056
|
+
});
|
|
5057
|
+
}
|
|
5058
|
+
return out;
|
|
5059
|
+
}
|
|
5060
|
+
function parseSystemTaskNotification(p, timestamp) {
|
|
5061
|
+
const taskId = typeof p.task_id === "string" && p.task_id.trim().length > 0 ? p.task_id.trim() : null;
|
|
5062
|
+
if (!taskId)
|
|
5063
|
+
return null;
|
|
5064
|
+
const toolUseId = typeof p.tool_use_id === "string" && p.tool_use_id.length > 0 ? p.tool_use_id : null;
|
|
5065
|
+
const summary = typeof p.summary === "string" ? p.summary : "";
|
|
5066
|
+
const nonneg = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined;
|
|
5067
|
+
const u = typeof p.usage === "object" && p.usage !== null ? p.usage : {};
|
|
5068
|
+
const tokens = nonneg(u.total_tokens);
|
|
5069
|
+
const toolUses = nonneg(u.tool_uses);
|
|
5070
|
+
const durationMs = nonneg(u.duration_ms);
|
|
5071
|
+
return {
|
|
5072
|
+
type: "task_notification",
|
|
5073
|
+
taskId: clip(taskId, 128),
|
|
5074
|
+
...toolUseId ? { toolUseId: clip(toolUseId, 128) } : {},
|
|
5075
|
+
status: typeof p.status === "string" && p.status.length > 0 ? p.status : "finished",
|
|
5076
|
+
summary: clip(summary.replace(/\s+/g, " ").trim(), NOTIFICATION_SUMMARY_MAX),
|
|
5077
|
+
...tokens !== undefined || toolUses !== undefined || durationMs !== undefined ? { usage: {
|
|
5078
|
+
...tokens !== undefined ? { tokens } : {},
|
|
5079
|
+
...toolUses !== undefined ? { toolUses } : {},
|
|
5080
|
+
...durationMs !== undefined ? { durationMs } : {}
|
|
5081
|
+
} } : {},
|
|
5082
|
+
timestamp
|
|
5083
|
+
};
|
|
5084
|
+
}
|
|
4528
5085
|
var CLAUDE_CODE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
4529
5086
|
var claudeCodeSpec = {
|
|
4530
5087
|
kind: "claude-code",
|
|
@@ -4533,11 +5090,16 @@ var claudeCodeSpec = {
|
|
|
4533
5090
|
acp: { command: "npx", args: ["--yes", CLAUDE_CODE_ACP_ADAPTER] },
|
|
4534
5091
|
install: "curl -fsSL https://claude.ai/install.sh | bash && " + 'REAL=$(readlink -f "$HOME/.local/bin/claude") && test -f "$REAL" && ' + 'sudo cp "$REAL" /usr/local/bin/claude && sudo chmod 0755 /usr/local/bin/claude',
|
|
4535
5092
|
promptPayload: (prompt) => prompt,
|
|
4536
|
-
|
|
5093
|
+
streamInput: {
|
|
5094
|
+
promptLine: (prompt) => JSON.stringify({ type: "user", message: { role: "user", content: prompt } }),
|
|
5095
|
+
messageLine: (text) => JSON.stringify({ type: "user", message: { role: "user", content: text } })
|
|
5096
|
+
},
|
|
5097
|
+
buildCommand: ({ promptPath, sessionId, model, cwd, effort, streamInput }) => {
|
|
4537
5098
|
const flags = [
|
|
4538
5099
|
"-p",
|
|
4539
5100
|
"--output-format stream-json",
|
|
4540
5101
|
"--verbose",
|
|
5102
|
+
...streamInput ? ["--input-format stream-json"] : [],
|
|
4541
5103
|
"--include-partial-messages",
|
|
4542
5104
|
"--dangerously-skip-permissions",
|
|
4543
5105
|
...model ? [`--model ${shellQuote(model)}`] : [],
|
|
@@ -4576,15 +5138,26 @@ var claudeCodeSpec = {
|
|
|
4576
5138
|
}
|
|
4577
5139
|
case "user": {
|
|
4578
5140
|
const message = p.message;
|
|
5141
|
+
if (typeof message?.content === "string") {
|
|
5142
|
+
return parseTaskNotifications(message.content, ts);
|
|
5143
|
+
}
|
|
4579
5144
|
const blocks2 = Array.isArray(message?.content) ? message.content : [];
|
|
4580
|
-
return blocks2.flatMap((b) =>
|
|
4581
|
-
type
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
5145
|
+
return blocks2.flatMap((b) => {
|
|
5146
|
+
if (b.type === "tool_result") {
|
|
5147
|
+
return [{
|
|
5148
|
+
type: "tool_result",
|
|
5149
|
+
toolUseId: String(b.tool_use_id ?? ""),
|
|
5150
|
+
output: toolResultText(b.content),
|
|
5151
|
+
isError: b.is_error === true,
|
|
5152
|
+
...parent,
|
|
5153
|
+
timestamp: ts
|
|
5154
|
+
}];
|
|
5155
|
+
}
|
|
5156
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
5157
|
+
return parseTaskNotifications(b.text, ts);
|
|
5158
|
+
}
|
|
5159
|
+
return [];
|
|
5160
|
+
});
|
|
4588
5161
|
}
|
|
4589
5162
|
case "stream_event": {
|
|
4590
5163
|
const ev = p.event;
|
|
@@ -4634,6 +5207,12 @@ var claudeCodeSpec = {
|
|
|
4634
5207
|
timestamp: ts
|
|
4635
5208
|
}];
|
|
4636
5209
|
}
|
|
5210
|
+
case "system": {
|
|
5211
|
+
if (p.subtype !== "task_notification")
|
|
5212
|
+
return [];
|
|
5213
|
+
const notification = parseSystemTaskNotification(p, ts);
|
|
5214
|
+
return notification ? [notification] : [];
|
|
5215
|
+
}
|
|
4637
5216
|
default:
|
|
4638
5217
|
return [];
|
|
4639
5218
|
}
|
|
@@ -5252,8 +5831,9 @@ function makeLocalSandboxProvider() {
|
|
|
5252
5831
|
stderr += chunk;
|
|
5253
5832
|
opts?.onStderr?.(chunk);
|
|
5254
5833
|
});
|
|
5255
|
-
proc
|
|
5256
|
-
|
|
5834
|
+
const ev = proc;
|
|
5835
|
+
ev.on("error", reject);
|
|
5836
|
+
ev.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr }));
|
|
5257
5837
|
});
|
|
5258
5838
|
},
|
|
5259
5839
|
spawnDuplex(cmd, opts) {
|
|
@@ -5269,8 +5849,9 @@ function makeLocalSandboxProvider() {
|
|
|
5269
5849
|
stderr += chunk;
|
|
5270
5850
|
});
|
|
5271
5851
|
const exited = new Promise((resolve, reject) => {
|
|
5272
|
-
proc
|
|
5273
|
-
|
|
5852
|
+
const ev = proc;
|
|
5853
|
+
ev.on("error", reject);
|
|
5854
|
+
ev.on("close", (code) => resolve({ exitCode: code ?? 0, stderr }));
|
|
5274
5855
|
});
|
|
5275
5856
|
return {
|
|
5276
5857
|
stdin: Writable.toWeb(proc.stdin),
|
|
@@ -6574,27 +7155,81 @@ like?" is answered by opening it on this desktop and screenshotting it — not b
|
|
|
6574
7155
|
reasoning about the code, and not by a headless render (which proves the process
|
|
6575
7156
|
starts, not that the thing draws). Verify visually before you report visually.
|
|
6576
7157
|
|
|
7158
|
+
**This is how you ACT on the web.** When the task is to DO something on a
|
|
7159
|
+
website — book, order, reserve, sign up, fill a form, operate a dashboard —
|
|
7160
|
+
and no connector or API covers it, the desktop browser IS the tool: \`ac-open\`
|
|
7161
|
+
the site, do the errand there, and show the human the screen at decision
|
|
7162
|
+
points (\`agentc display desktop\` in a cloud session). Research/search tools
|
|
7163
|
+
answer QUESTIONS; an errand is an ACTION — "book me a table" means open the
|
|
7164
|
+
booking site and book it, never a research report of options.
|
|
7165
|
+
|
|
6577
7166
|
- **Input** — \`xdotool\` against \`DISPLAY=:0\`: \`DISPLAY=:0 xdotool mousemove <x> <y>\`,
|
|
6578
7167
|
\`DISPLAY=:0 xdotool click 1\` (1=left, 3=right), \`DISPLAY=:0 xdotool type 'text'\`,
|
|
6579
7168
|
\`DISPLAY=:0 xdotool key Return\` (also \`ctrl+c\`, \`Tab\`, \`super\`, …).
|
|
6580
7169
|
- **Screenshots** — \`scrot\` (or ImageMagick's \`import\`):
|
|
6581
7170
|
\`DISPLAY=:0 scrot /tmp/screen.png\`, then READ the PNG to see the screen,
|
|
6582
7171
|
before and after you act. A screenshot is your only eyes here.
|
|
6583
|
-
- **
|
|
6584
|
-
\`
|
|
7172
|
+
- **The browser is chromium, preinstalled** — headful, on this display
|
|
7173
|
+
(\`command -v chromium\` to confirm on an older machine). If an older machine
|
|
7174
|
+
is missing it, the platform is already installing it in the background from
|
|
7175
|
+
boot — \`ac-open <url>\` tells you when that is the case; retry it in ~30s.
|
|
7176
|
+
Only if \`ac-open\` reports the background install FAILED do you relay that
|
|
7177
|
+
one line to the human — never an apt-get expedition of your own.
|
|
7178
|
+
- **Launching apps — use \`ac-open\`, never a plain \`&\`.** A GUI process
|
|
7179
|
+
launched with \`<app> &\` DIES the moment your shell command returns — the
|
|
7180
|
+
sandbox reaps each command's process group, so "the window vanished when
|
|
7181
|
+
the shell finished" is that reaping, not a broken app. \`ac-open\` is the
|
|
7182
|
+
platform launcher that survives it (\`command -v ac-open\` on older machines):
|
|
7183
|
+
|
|
7184
|
+
ac-open https://github.com # the browser — a running instance gets a tab
|
|
7185
|
+
ac-open ./report.html # a local file, in the browser
|
|
7186
|
+
ac-open . # a directory, in the file manager
|
|
7187
|
+
ac-open gimp # any GUI app by command name
|
|
7188
|
+
|
|
7189
|
+
It detaches the app into its own session (setsid, stdio off your command's
|
|
7190
|
+
pipes), records a pidfile + log under \`/tmp/.ac-desktop-open.<uid>/\`
|
|
7191
|
+
(per-uid — yours is \`/tmp/.ac-desktop-open.$(id -u)\`), and
|
|
7192
|
+
re-invoking it for a running app FOCUSES the existing window instead of
|
|
7193
|
+
spawning a second copy. \`xdg-open\` and \`sensible-browser\` route through
|
|
7194
|
+
it too. The whole recipe for looking at a page: \`ac-open <url>\`, then
|
|
7195
|
+
\`sleep 5\`, then \`DISPLAY=:0 scrot /tmp/screen.png\` and read it. Without
|
|
7196
|
+
\`ac-open\` (older machine), detach by hand:
|
|
7197
|
+
\`setsid <app> </dev/null >/tmp/app.log 2>&1 &\` — and note **chromium as
|
|
7198
|
+
root also needs \`--no-sandbox\`** (nested sandbox; \`ac-open\` and the baked
|
|
7199
|
+
chromium defaults already handle it).
|
|
7200
|
+
- **Two things that trip agents up, both normal:**
|
|
6585
7201
|
- a GUI app needs a **beat to map its window** — screenshot, and if you see
|
|
6586
7202
|
only wallpaper, wait a couple of seconds and screenshot again before
|
|
6587
7203
|
concluding anything;
|
|
6588
|
-
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
If a window still never appears, read the app's own log (\`/tmp/*.log\`) — the
|
|
6593
|
-
desktop is not the thing that failed. Do NOT abandon it for a headless
|
|
6594
|
-
screenshot: headless cannot tell you what the human will see.
|
|
7204
|
+
- if a window still never appears, read the app's own log
|
|
7205
|
+
(\`/tmp/.ac-desktop-open.$(id -u)/*.log\`, \`/tmp/*.log\`) — the desktop is not the
|
|
7206
|
+
thing that failed. Do NOT abandon it for a headless
|
|
7207
|
+
screenshot: headless cannot tell you what the human will see.
|
|
6595
7208
|
- **A human can watch** — the session header carries a **Desktop** button in the
|
|
6596
7209
|
dashboard, and what a teammate sees there is exactly this display. The desktop
|
|
6597
7210
|
runs whether or not anyone is looking; never wait for a viewer.
|
|
7211
|
+
- **Show the human the screen** — in a cloud session,
|
|
7212
|
+
\`agentc display desktop --note "<caption>"\` captures this display and posts
|
|
7213
|
+
it into the conversation as a snapshot card with an "Open desktop" door to
|
|
7214
|
+
the live view. Use it to report visual results, and ALWAYS when you hit a
|
|
7215
|
+
wall on the desktop that only a human can clear — a login form, a 2FA
|
|
7216
|
+
prompt, a CAPTCHA, an unexpected dialog: snapshot it so they SEE the wall,
|
|
7217
|
+
then ask (AskUserQuestion when you have it) and wait; never guess
|
|
7218
|
+
credentials or click around a wall. The rule is SCREEN FOR ACTIONS,
|
|
7219
|
+
VAULT FOR SECRETS. For non-sensitive interaction that needs the human's
|
|
7220
|
+
own hands or judgment — pick an option, review a page, solve a CAPTCHA —
|
|
7221
|
+
the display + ask pair is right: the platform merges them into ONE live
|
|
7222
|
+
desktop card — the human clicks in, acts on the live screen, and answers
|
|
7223
|
+
"I'm done" to hand it back; treat that answer as the wall being cleared,
|
|
7224
|
+
re-check the screen, and continue. For SECRETS — a password, payment
|
|
7225
|
+
details, any sensitive value —
|
|
7226
|
+
\`agentc secrets session request <KEY...> --reason "<why>" --wait\` mints a
|
|
7227
|
+
secure vault link (a one-tap approval when the user has these saved as a
|
|
7228
|
+
personal set); the values land in the session env and YOU type them into
|
|
7229
|
+
the site on the user's behalf. Never ask the human to type a password or
|
|
7230
|
+
card number into this machine's browser, and never suggest they "log in
|
|
7231
|
+
on the Desktop view" — the vault carries the secret, then you act with
|
|
7232
|
+
it. A one-time 2FA code from their phone is the chat-OK exception.
|
|
6598
7233
|
|
|
6599
7234
|
Nothing here changes the credentials rule above: tokens are injected at the
|
|
6600
7235
|
network layer, never present on the desktop or in any file you can read — so
|
|
@@ -6685,14 +7320,49 @@ origin of its own — absolute asset paths and client-side routing work, the
|
|
|
6685
7320
|
whole app is navigable — so serve normally and let the platform address it;
|
|
6686
7321
|
never rewrite your app to a path prefix.
|
|
6687
7322
|
|
|
7323
|
+
## Durable services — the machine is cattle, the manifest is the pet (cloud sessions)
|
|
7324
|
+
|
|
7325
|
+
Parking preserves detached processes; a machine RECYCLE (resize, eviction,
|
|
7326
|
+
failed reconnect) does not — every process and every byte off the drive is
|
|
7327
|
+
discarded, and recycles are normal. When you start a long-running service the
|
|
7328
|
+
human will rely on across turns (a dev server, a docker compose stack, a
|
|
7329
|
+
database), record it in \`.ac/services.yml\` at the drive root so the platform
|
|
7330
|
+
relaunches it automatically on the next fresh machine:
|
|
7331
|
+
|
|
7332
|
+
agentc services add <name> --command '<cmd>' # record a service
|
|
7333
|
+
agentc services list # manifest + live status
|
|
7334
|
+
agentc services restore # run the manifest now
|
|
7335
|
+
agentc services remove <name>
|
|
7336
|
+
|
|
7337
|
+
Each entry can carry \`cwd\`, \`port\`, a bounded \`health\` probe (cmd or
|
|
7338
|
+
http), one-time \`setup\` (e.g. \`docker compose pull\`), and \`data\` hooks.
|
|
7339
|
+
After a recycle the platform posts "Machine restarted — restored N services"
|
|
7340
|
+
into the conversation; on seeing it, VERIFY health rather than rebuilding —
|
|
7341
|
+
logs live at \`/tmp/ac-services/<name>.log\`. Data honesty: sandbox-local
|
|
7342
|
+
database state dies with the machine. Keep seeds/dumps ON THE DRIVE; declare
|
|
7343
|
+
\`data.restore\` (reload on fresh boot) and \`data.dump\` (written before a
|
|
7344
|
+
DELIBERATE recycle such as a resize — evictions give no warning, so treat the
|
|
7345
|
+
drive copy as the truth).
|
|
7346
|
+
|
|
6688
7347
|
## Tools in this environment
|
|
6689
7348
|
|
|
6690
7349
|
- \`agentc\` — Agent Compose CLI (your primary interface; authed from env)
|
|
6691
7350
|
- \`@agent-compose/sdk\` — installed in /workspace for writing workflows
|
|
6692
7351
|
- \`/ac:*\` Claude Code skills — slash commands for the above
|
|
6693
|
-
- \`
|
|
7352
|
+
- \`rtk\`, \`bun\`
|
|
6694
7353
|
- \`xdotool\` / \`scrot\` — drive + screenshot the desktop (if this machine has one; see Computer Use)
|
|
6695
|
-
-
|
|
7354
|
+
- \`chromium\` — the desktop browser; \`ac-open <url|file|app>\` — open it on the
|
|
7355
|
+
desktop, detached (survives your command; see Computer Use)
|
|
7356
|
+
- A world-writable \`/workspace\` working directory
|
|
7357
|
+
|
|
7358
|
+
If a system capability you need is genuinely missing — no browser, no display,
|
|
7359
|
+
no \`ac-open\`, a daemon that isn't there — say so to the human in ONE honest
|
|
7360
|
+
line (what is missing and what it blocks) instead of mounting a
|
|
7361
|
+
package-manager expedition. An in-session \`apt-get install\` dies with the
|
|
7362
|
+
sandbox, burns turns, and hides the real gap; missing platform capabilities
|
|
7363
|
+
are the platform's to bake in, and \`agentc pause\` is the door to ask through.
|
|
7364
|
+
(Your own project's dependencies are different — installing those is normal
|
|
7365
|
+
work.)`;
|
|
6696
7366
|
function buildAddedSessionBrief(p) {
|
|
6697
7367
|
const dashboardSection = p.dashboardUrl === null ? "" : `
|
|
6698
7368
|
|
|
@@ -6999,6 +7669,565 @@ async function agent(opts) {
|
|
|
6999
7669
|
inboxAbort.abort();
|
|
7000
7670
|
}
|
|
7001
7671
|
}
|
|
7672
|
+
// src/agent/desktop-open.ts
|
|
7673
|
+
var AC_OPEN_PATH = "/usr/local/bin/ac-open";
|
|
7674
|
+
var AC_OPEN_STATE_DIR = "/tmp/.ac-desktop-open";
|
|
7675
|
+
var AC_OPEN_SHIM_PATHS = [
|
|
7676
|
+
"/usr/local/bin/xdg-open",
|
|
7677
|
+
"/usr/local/bin/sensible-browser"
|
|
7678
|
+
];
|
|
7679
|
+
var BROWSER_BACKFILL_PIDFILE = `${AC_OPEN_STATE_DIR}/browser-install.pid`;
|
|
7680
|
+
var BROWSER_BACKFILL_LOG = `${AC_OPEN_STATE_DIR}/browser-install.log`;
|
|
7681
|
+
var BROWSER_BACKFILL_FAILED = `${AC_OPEN_STATE_DIR}/browser-install.failed`;
|
|
7682
|
+
var BROWSER_INSTALL_IN_PROGRESS_MSG = "ac-open: no browser YET — the platform is installing chromium in the background right now; retry this exact command in ~30s";
|
|
7683
|
+
var AC_OPEN_SCRIPT = `#!/bin/sh
|
|
7684
|
+
# ac-open — open a URL, file, or GUI app on this machine's desktop, DETACHED.
|
|
7685
|
+
#
|
|
7686
|
+
# ac-open https://github.com the browser (a running instance gets a tab)
|
|
7687
|
+
# ac-open ./report.html a local file, in the browser
|
|
7688
|
+
# ac-open . a directory, in the file manager
|
|
7689
|
+
# ac-open gimp [args...] any GUI app by command name
|
|
7690
|
+
#
|
|
7691
|
+
# Why: the sandbox reaps each exec'd command's process group when the command
|
|
7692
|
+
# returns, so a GUI app launched with a plain \`&\` dies the moment the
|
|
7693
|
+
# launching shell finishes. ac-open detaches the app into its own session
|
|
7694
|
+
# (setsid, stdio off the exec's pipes), records a pidfile, and returns at
|
|
7695
|
+
# once; re-invoking it for a running app focuses the existing window instead
|
|
7696
|
+
# of spawning a second copy. Logs + pidfiles: /tmp/.ac-desktop-open.<uid>/.
|
|
7697
|
+
# Written by agent-compose (sdk/src/agent/desktop-open.ts) — do not edit in place.
|
|
7698
|
+
set -u
|
|
7699
|
+
export DISPLAY="\${DISPLAY:-:0}"
|
|
7700
|
+
# Per-UID state (pidfiles + logs): the shared dir is root-owned when the
|
|
7701
|
+
# backfill created it, and a user-side >>$LOG into a root 0755 dir fails
|
|
7702
|
+
# BEFORE the app runs — the false "exited immediately" with an empty log.
|
|
7703
|
+
D="/tmp/.ac-desktop-open.$(id -u)"
|
|
7704
|
+
# Shared, root-written backfill markers — READ-ONLY here.
|
|
7705
|
+
M=/tmp/.ac-desktop-open
|
|
7706
|
+
mkdir -p "$D" 2>/dev/null || true
|
|
7707
|
+
[ -w "$D" ] || { echo "ac-open: state dir $D is not writable by uid $(id -u)" >&2; exit 1; }
|
|
7708
|
+
|
|
7709
|
+
[ $# -ge 1 ] || { echo 'usage: ac-open <url|file|dir|app> [args...]' >&2; exit 2; }
|
|
7710
|
+
TARGET=$1; shift
|
|
7711
|
+
|
|
7712
|
+
# No display = no desktop on this image. One honest line, no expedition.
|
|
7713
|
+
if ! timeout 3 xdotool getdisplaygeometry >/dev/null 2>&1; then
|
|
7714
|
+
echo "ac-open: no desktop display on $DISPLAY — this machine has no GUI session up" >&2
|
|
7715
|
+
exit 1
|
|
7716
|
+
fi
|
|
7717
|
+
|
|
7718
|
+
pick_browser() {
|
|
7719
|
+
for b in chromium chromium-browser x-www-browser google-chrome; do
|
|
7720
|
+
command -v "$b" >/dev/null 2>&1 && { echo "$b"; return 0; }
|
|
7721
|
+
done
|
|
7722
|
+
return 1
|
|
7723
|
+
}
|
|
7724
|
+
|
|
7725
|
+
# Best-effort: raise the newest visible window whose class matches $1.
|
|
7726
|
+
focus_class() {
|
|
7727
|
+
WID=$(xdotool search --onlyvisible --class "$1" 2>/dev/null | tail -1) || WID=''
|
|
7728
|
+
[ -n "\${WID:-}" ] && xdotool windowactivate "$WID" 2>/dev/null || true
|
|
7729
|
+
}
|
|
7730
|
+
|
|
7731
|
+
# Same-UID instance probe: another identity's instance shares neither profile
|
|
7732
|
+
# nor single-instance socket, so only our own uid's counts as "running".
|
|
7733
|
+
alive_same_uid() { pgrep -x -u "$(id -u)" "$1" >/dev/null 2>&1; }
|
|
7734
|
+
|
|
7735
|
+
# Singleton apps (browser, file manager): the second invocation hands the
|
|
7736
|
+
# target to the running instance and exits — that IS the idempotent path, so
|
|
7737
|
+
# spawn detached every time and only liveness-check a FRESH instance.
|
|
7738
|
+
singleton_open() { # $1 = state key, rest = command + target
|
|
7739
|
+
NAME=$1; shift
|
|
7740
|
+
BIN=$(basename "$1")
|
|
7741
|
+
PIDFILE=$D/$NAME.pid; LOG=$D/$NAME.log
|
|
7742
|
+
RUNNING=0
|
|
7743
|
+
alive_same_uid "$BIN" && RUNNING=1
|
|
7744
|
+
[ "$RUNNING" = 0 ] && [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null && RUNNING=1
|
|
7745
|
+
setsid "$@" </dev/null >>"$LOG" 2>&1 &
|
|
7746
|
+
PID=$!
|
|
7747
|
+
if [ "$RUNNING" = 1 ]; then
|
|
7748
|
+
sleep 1
|
|
7749
|
+
focus_class "$BIN"
|
|
7750
|
+
echo "ac-open: handed off to the running $BIN — no second window"
|
|
7751
|
+
return 0
|
|
7752
|
+
fi
|
|
7753
|
+
echo "$PID" > "$PIDFILE"
|
|
7754
|
+
sleep 1
|
|
7755
|
+
if ! kill -0 "$PID" 2>/dev/null; then
|
|
7756
|
+
# Died within the liveness window — but if OUR instance of $BIN is alive
|
|
7757
|
+
# NOW, this was the single-instance HAND-OFF: the pre-check raced a
|
|
7758
|
+
# still-starting instance, the fresh process handed the target over and
|
|
7759
|
+
# exited 0. That is success (N calls = N tabs), never a crash report.
|
|
7760
|
+
if alive_same_uid "$BIN"; then
|
|
7761
|
+
focus_class "$BIN"
|
|
7762
|
+
echo "ac-open: handed off to the running $BIN — no second window"
|
|
7763
|
+
return 0
|
|
7764
|
+
fi
|
|
7765
|
+
TAIL=$(tail -c 300 "$LOG" 2>/dev/null | tr '\\n' ' ')
|
|
7766
|
+
echo "ac-open: $BIN exited immediately: \${TAIL:-no output captured — see $LOG}" >&2
|
|
7767
|
+
exit 1
|
|
7768
|
+
fi
|
|
7769
|
+
focus_class "$BIN"
|
|
7770
|
+
echo "ac-open: launched $BIN (pid $PID), detached — it survives this command"
|
|
7771
|
+
}
|
|
7772
|
+
|
|
7773
|
+
open_url() { # $1 = url
|
|
7774
|
+
B=$(pick_browser) || {
|
|
7775
|
+
# Grandfathered image mid-heal: the boot-time desktop-ensure found no
|
|
7776
|
+
# browser and detached an apt install (browserBackfillCmd — the pidfile
|
|
7777
|
+
# below is ITS single-flight marker, root-written in the SHARED dir).
|
|
7778
|
+
# Third honest state: arriving.
|
|
7779
|
+
if [ -f "$M/browser-install.pid" ] && kill -0 "$(cat "$M/browser-install.pid" 2>/dev/null)" 2>/dev/null; then
|
|
7780
|
+
echo "${BROWSER_INSTALL_IN_PROGRESS_MSG}" >&2
|
|
7781
|
+
exit 75
|
|
7782
|
+
fi
|
|
7783
|
+
if [ -f "$M/browser-install.failed" ]; then
|
|
7784
|
+
TAIL=$(tail -c 300 "$M/browser-install.log" 2>/dev/null | tr '\\n' ' ')
|
|
7785
|
+
echo "ac-open: the platform's background chromium install FAILED: \${TAIL:-see $M/browser-install.log} — report that cause in one line; do not apt-get one yourself." >&2
|
|
7786
|
+
exit 1
|
|
7787
|
+
fi
|
|
7788
|
+
echo "ac-open: no graphical browser on this image (expected chromium) — cannot open $1. Report the missing browser in one line; do not apt-get one mid-session." >&2
|
|
7789
|
+
exit 1
|
|
7790
|
+
}
|
|
7791
|
+
singleton_open browser "$B" "$1"
|
|
7792
|
+
}
|
|
7793
|
+
|
|
7794
|
+
open_app() { # $1 = command, rest = args
|
|
7795
|
+
command -v "$1" >/dev/null 2>&1 || {
|
|
7796
|
+
echo "ac-open: '$1' is not installed on this machine — report the missing tool in one line instead of package-hunting for it" >&2
|
|
7797
|
+
exit 127
|
|
7798
|
+
}
|
|
7799
|
+
BIN=$(basename "$1"); PIDFILE=$D/app-$BIN.pid; LOG=$D/app-$BIN.log
|
|
7800
|
+
OLD=$(cat "$PIDFILE" 2>/dev/null || true)
|
|
7801
|
+
if [ -n "\${OLD:-}" ] && kill -0 "$OLD" 2>/dev/null; then
|
|
7802
|
+
WID=$(xdotool search --onlyvisible --pid "$OLD" 2>/dev/null | tail -1) || WID=''
|
|
7803
|
+
[ -z "\${WID:-}" ] && { WID=$(xdotool search --onlyvisible --class "$BIN" 2>/dev/null | tail -1) || WID=''; }
|
|
7804
|
+
[ -n "\${WID:-}" ] && xdotool windowactivate "$WID" 2>/dev/null || true
|
|
7805
|
+
echo "ac-open: $BIN is already running (pid $OLD) — focused it instead of starting a second copy"
|
|
7806
|
+
exit 0
|
|
7807
|
+
fi
|
|
7808
|
+
CMD=$1; shift
|
|
7809
|
+
setsid "$CMD" "$@" </dev/null >>"$LOG" 2>&1 &
|
|
7810
|
+
PID=$!
|
|
7811
|
+
echo "$PID" > "$PIDFILE"
|
|
7812
|
+
sleep 1
|
|
7813
|
+
if ! kill -0 "$PID" 2>/dev/null; then
|
|
7814
|
+
# Same hand-off grace as the singletons: VS Code and friends are
|
|
7815
|
+
# single-instance too — a fresh spawn that exits at once while our own
|
|
7816
|
+
# instance is alive HANDED OFF, it did not crash.
|
|
7817
|
+
if alive_same_uid "$BIN"; then
|
|
7818
|
+
focus_class "$BIN"
|
|
7819
|
+
echo "ac-open: handed off to the running $BIN — no second window"
|
|
7820
|
+
exit 0
|
|
7821
|
+
fi
|
|
7822
|
+
TAIL=$(tail -c 300 "$LOG" 2>/dev/null | tr '\\n' ' ')
|
|
7823
|
+
echo "ac-open: $BIN exited immediately: \${TAIL:-no output captured — see $LOG}" >&2
|
|
7824
|
+
exit 1
|
|
7825
|
+
fi
|
|
7826
|
+
echo "ac-open: launched $BIN (pid $PID), detached — it survives this command"
|
|
7827
|
+
}
|
|
7828
|
+
|
|
7829
|
+
case "$TARGET" in
|
|
7830
|
+
http://*|https://*|file://*|about:*|chrome://*)
|
|
7831
|
+
open_url "$TARGET" ;;
|
|
7832
|
+
localhost|localhost:*|localhost/*|127.0.0.1|127.0.0.1:*|127.0.0.1/*)
|
|
7833
|
+
open_url "http://$TARGET" ;;
|
|
7834
|
+
*)
|
|
7835
|
+
if [ -d "$TARGET" ]; then
|
|
7836
|
+
command -v pcmanfm >/dev/null 2>&1 || {
|
|
7837
|
+
echo "ac-open: no file manager on this image (expected pcmanfm) — report it in one line" >&2
|
|
7838
|
+
exit 1
|
|
7839
|
+
}
|
|
7840
|
+
case "$TARGET" in /*) ABS=$TARGET ;; *) ABS=$PWD/$TARGET ;; esac
|
|
7841
|
+
singleton_open files pcmanfm "$ABS"
|
|
7842
|
+
elif [ -f "$TARGET" ]; then
|
|
7843
|
+
case "$TARGET" in /*) ABS=$TARGET ;; *) ABS=$PWD/$TARGET ;; esac
|
|
7844
|
+
open_url "file://$ABS"
|
|
7845
|
+
else
|
|
7846
|
+
open_app "$TARGET" "$@"
|
|
7847
|
+
fi ;;
|
|
7848
|
+
esac
|
|
7849
|
+
`;
|
|
7850
|
+
var AC_OPEN_SHIM = `#!/bin/sh
|
|
7851
|
+
# agent-compose: generic open routes through ac-open (the detaching launcher).
|
|
7852
|
+
exec ${AC_OPEN_PATH} "$@"
|
|
7853
|
+
`;
|
|
7854
|
+
function installDesktopOpenCmd() {
|
|
7855
|
+
const scriptB64 = Buffer.from(AC_OPEN_SCRIPT, "utf8").toString("base64");
|
|
7856
|
+
const shimB64 = Buffer.from(AC_OPEN_SHIM, "utf8").toString("base64");
|
|
7857
|
+
return [
|
|
7858
|
+
`printf %s '${scriptB64}' | base64 -d > ${AC_OPEN_PATH}`,
|
|
7859
|
+
`chmod 0755 ${AC_OPEN_PATH}`,
|
|
7860
|
+
`sh -n ${AC_OPEN_PATH}`,
|
|
7861
|
+
...AC_OPEN_SHIM_PATHS.flatMap((p) => [
|
|
7862
|
+
`printf %s '${shimB64}' | base64 -d > ${p}`,
|
|
7863
|
+
`chmod 0755 ${p}`,
|
|
7864
|
+
`sh -n ${p}`
|
|
7865
|
+
])
|
|
7866
|
+
].join(" && ");
|
|
7867
|
+
}
|
|
7868
|
+
var CHROMIUM_DEFAULTS_PATH = "/etc/chromium.d/00-ac-defaults";
|
|
7869
|
+
var CHROMIUM_DEFAULTS = `# agent-compose session-desktop chromium defaults
|
|
7870
|
+
# (single-sourced in sdk/src/agent/desktop-open.ts — sourced by /usr/bin/chromium)
|
|
7871
|
+
CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-first-run --no-default-browser-check --password-store=basic --disable-session-crashed-bubble --disable-dev-shm-usage --start-maximized --disable-gpu"
|
|
7872
|
+
if [ "$(id -u)" = "0" ]; then CHROMIUM_FLAGS="$CHROMIUM_FLAGS --no-sandbox"; fi
|
|
7873
|
+
`;
|
|
7874
|
+
function installChromiumDefaultsCmd() {
|
|
7875
|
+
const b64 = Buffer.from(CHROMIUM_DEFAULTS, "utf8").toString("base64");
|
|
7876
|
+
return [
|
|
7877
|
+
"mkdir -p /etc/chromium.d",
|
|
7878
|
+
`printf %s ${b64} | base64 -d > ${CHROMIUM_DEFAULTS_PATH}`,
|
|
7879
|
+
`chmod 0644 ${CHROMIUM_DEFAULTS_PATH}`,
|
|
7880
|
+
`sh -n ${CHROMIUM_DEFAULTS_PATH}`
|
|
7881
|
+
].join(" && ");
|
|
7882
|
+
}
|
|
7883
|
+
function browserBackfillCmd() {
|
|
7884
|
+
const pid = BROWSER_BACKFILL_PIDFILE;
|
|
7885
|
+
return `mkdir -p ${AC_OPEN_STATE_DIR} && ` + `if command -v chromium >/dev/null 2>&1; then echo backfill=present; ` + `elif [ -f ${pid} ] && kill -0 "$(cat ${pid} 2>/dev/null)" 2>/dev/null; then echo backfill=in-progress; ` + `else rm -f ${BROWSER_BACKFILL_FAILED}; ` + `setsid sh -c 'if apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get install -y -q chromium && command -v chromium >/dev/null 2>&1 && ${installChromiumDefaultsCmd()}; then echo backfill-install=ok; else echo backfill-install=failed; touch ${BROWSER_BACKFILL_FAILED}; fi; rm -f ${pid}' </dev/null >>${BROWSER_BACKFILL_LOG} 2>&1 & ` + `echo $! > ${pid}; echo backfill=started; fi`;
|
|
7886
|
+
}
|
|
7887
|
+
function browserBackfillStatusCmd() {
|
|
7888
|
+
return `if command -v chromium >/dev/null 2>&1; then echo backfill=ok; ` + `elif [ -f ${BROWSER_BACKFILL_FAILED} ]; then echo backfill=failed; tail -c 300 ${BROWSER_BACKFILL_LOG} 2>/dev/null | tr '\\n' ' '; ` + `elif [ -f ${BROWSER_BACKFILL_PIDFILE} ] && kill -0 "$(cat ${BROWSER_BACKFILL_PIDFILE} 2>/dev/null)" 2>/dev/null; then echo backfill=in-progress; ` + `else echo backfill=lost; fi`;
|
|
7889
|
+
}
|
|
7890
|
+
// src/agent/services-manifest.ts
|
|
7891
|
+
var SERVICES_MANIFEST_RELPATH = ".ac/services.yml";
|
|
7892
|
+
var SERVICES_MAX = 16;
|
|
7893
|
+
var SERVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/;
|
|
7894
|
+
var TOP_KEYS = new Set(["version", "services"]);
|
|
7895
|
+
var ENTRY_KEYS = new Set(["name", "command", "cwd", "port", "env", "setup", "health", "data"]);
|
|
7896
|
+
var HEALTH_KEYS = new Set(["cmd", "http"]);
|
|
7897
|
+
var DATA_KEYS = new Set(["dump", "restore"]);
|
|
7898
|
+
function lex(text) {
|
|
7899
|
+
const out = [];
|
|
7900
|
+
const raw = text.split(/\r?\n/);
|
|
7901
|
+
for (let i = 0;i < raw.length; i++) {
|
|
7902
|
+
const line = raw[i];
|
|
7903
|
+
const trimmed = line.trim();
|
|
7904
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
7905
|
+
continue;
|
|
7906
|
+
const indent = line.length - line.trimStart().length;
|
|
7907
|
+
const dash = trimmed.startsWith("- ") || trimmed === "-";
|
|
7908
|
+
out.push({
|
|
7909
|
+
no: i + 1,
|
|
7910
|
+
indent,
|
|
7911
|
+
dash,
|
|
7912
|
+
text: dash ? trimmed.slice(1).trim() : trimmed
|
|
7913
|
+
});
|
|
7914
|
+
}
|
|
7915
|
+
return out;
|
|
7916
|
+
}
|
|
7917
|
+
function splitKey(text) {
|
|
7918
|
+
if (text.endsWith(":") && !text.includes(": ")) {
|
|
7919
|
+
const key2 = text.slice(0, -1).trim();
|
|
7920
|
+
return /^[A-Za-z_][\w-]*$/.test(key2) ? { key: key2, value: "" } : null;
|
|
7921
|
+
}
|
|
7922
|
+
const idx = text.indexOf(": ");
|
|
7923
|
+
if (idx === -1)
|
|
7924
|
+
return null;
|
|
7925
|
+
const key = text.slice(0, idx).trim();
|
|
7926
|
+
if (!/^[A-Za-z_][\w-]*$/.test(key))
|
|
7927
|
+
return null;
|
|
7928
|
+
return { key, value: text.slice(idx + 2).trim() };
|
|
7929
|
+
}
|
|
7930
|
+
function scalar(value, no, errors) {
|
|
7931
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
7932
|
+
return value.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
7933
|
+
}
|
|
7934
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
|
7935
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
7936
|
+
}
|
|
7937
|
+
if (value === "|" || value === ">" || value.startsWith("| ") || value.startsWith("> ")) {
|
|
7938
|
+
errors.push(`line ${no}: block scalars (| / >) are not supported — keep commands single-line`);
|
|
7939
|
+
return "";
|
|
7940
|
+
}
|
|
7941
|
+
return value;
|
|
7942
|
+
}
|
|
7943
|
+
function parseServicesManifest(text) {
|
|
7944
|
+
const errors = [];
|
|
7945
|
+
if (text.length > 64 * 1024) {
|
|
7946
|
+
return { manifest: null, errors: ["manifest exceeds 64KB — this file is a service list, not a data store"] };
|
|
7947
|
+
}
|
|
7948
|
+
const lines = lex(text);
|
|
7949
|
+
if (lines.length === 0)
|
|
7950
|
+
return { manifest: null, errors: ["manifest is empty"] };
|
|
7951
|
+
let version = null;
|
|
7952
|
+
const services = [];
|
|
7953
|
+
let current = null;
|
|
7954
|
+
let currentIndent = -1;
|
|
7955
|
+
let nested = null;
|
|
7956
|
+
let nestedIndent = -1;
|
|
7957
|
+
let inServices = false;
|
|
7958
|
+
const finish = (entry) => {
|
|
7959
|
+
if (!entry)
|
|
7960
|
+
return;
|
|
7961
|
+
services.push(entry);
|
|
7962
|
+
};
|
|
7963
|
+
for (const line of lines) {
|
|
7964
|
+
const kv = splitKey(line.text);
|
|
7965
|
+
if (!kv) {
|
|
7966
|
+
errors.push(`line ${line.no}: not a \`key: value\` mapping — flow collections and bare scalars are not supported`);
|
|
7967
|
+
continue;
|
|
7968
|
+
}
|
|
7969
|
+
if (line.indent === 0 && !line.dash) {
|
|
7970
|
+
inServices = false;
|
|
7971
|
+
nested = null;
|
|
7972
|
+
finish(current);
|
|
7973
|
+
current = null;
|
|
7974
|
+
if (!TOP_KEYS.has(kv.key)) {
|
|
7975
|
+
errors.push(`line ${line.no}: unknown top-level key \`${kv.key}\` (allowed: version, services)`);
|
|
7976
|
+
continue;
|
|
7977
|
+
}
|
|
7978
|
+
if (kv.key === "version") {
|
|
7979
|
+
const v = Number(scalar(kv.value, line.no, errors));
|
|
7980
|
+
if (v !== 1)
|
|
7981
|
+
errors.push(`line ${line.no}: unsupported manifest version \`${kv.value}\` (this build understands version 1)`);
|
|
7982
|
+
else
|
|
7983
|
+
version = 1;
|
|
7984
|
+
} else {
|
|
7985
|
+
if (kv.value !== "")
|
|
7986
|
+
errors.push(`line ${line.no}: \`services:\` must open a list, not carry a value`);
|
|
7987
|
+
inServices = true;
|
|
7988
|
+
}
|
|
7989
|
+
continue;
|
|
7990
|
+
}
|
|
7991
|
+
if (!inServices) {
|
|
7992
|
+
errors.push(`line ${line.no}: unexpected indented line outside \`services:\``);
|
|
7993
|
+
continue;
|
|
7994
|
+
}
|
|
7995
|
+
if (line.dash) {
|
|
7996
|
+
finish(current);
|
|
7997
|
+
current = { name: "", command: "" };
|
|
7998
|
+
currentIndent = line.indent;
|
|
7999
|
+
nested = null;
|
|
8000
|
+
if (kv.key !== "name") {
|
|
8001
|
+
errors.push(`line ${line.no}: each service entry must start with \`- name: <name>\``);
|
|
8002
|
+
continue;
|
|
8003
|
+
}
|
|
8004
|
+
current.name = scalar(kv.value, line.no, errors);
|
|
8005
|
+
continue;
|
|
8006
|
+
}
|
|
8007
|
+
if (!current) {
|
|
8008
|
+
errors.push(`line ${line.no}: expected \`- name: <name>\` to open a service entry`);
|
|
8009
|
+
continue;
|
|
8010
|
+
}
|
|
8011
|
+
if (nested && line.indent > nestedIndent) {
|
|
8012
|
+
const allowed = nested === "health" ? HEALTH_KEYS : DATA_KEYS;
|
|
8013
|
+
if (!allowed.has(kv.key)) {
|
|
8014
|
+
errors.push(`line ${line.no}: unknown \`${nested}\` key \`${kv.key}\` (allowed: ${[...allowed].join(", ")})`);
|
|
8015
|
+
continue;
|
|
8016
|
+
}
|
|
8017
|
+
const value2 = scalar(kv.value, line.no, errors);
|
|
8018
|
+
if (value2 === "") {
|
|
8019
|
+
errors.push(`line ${line.no}: \`${nested}.${kv.key}\` needs a value`);
|
|
8020
|
+
continue;
|
|
8021
|
+
}
|
|
8022
|
+
const bucket = current[nested] ??= {};
|
|
8023
|
+
bucket[kv.key] = value2;
|
|
8024
|
+
continue;
|
|
8025
|
+
}
|
|
8026
|
+
nested = null;
|
|
8027
|
+
if (line.indent <= currentIndent) {
|
|
8028
|
+
errors.push(`line ${line.no}: bad indentation — service fields must be indented under their \`- name:\` line`);
|
|
8029
|
+
continue;
|
|
8030
|
+
}
|
|
8031
|
+
if (!ENTRY_KEYS.has(kv.key)) {
|
|
8032
|
+
errors.push(`line ${line.no}: unknown service key \`${kv.key}\` (allowed: ${[...ENTRY_KEYS].join(", ")})`);
|
|
8033
|
+
continue;
|
|
8034
|
+
}
|
|
8035
|
+
if (kv.key === "health" || kv.key === "data") {
|
|
8036
|
+
if (kv.value !== "") {
|
|
8037
|
+
errors.push(`line ${line.no}: \`${kv.key}:\` opens a nested map — put cmd/http (or dump/restore) on indented lines below`);
|
|
8038
|
+
continue;
|
|
8039
|
+
}
|
|
8040
|
+
nested = kv.key;
|
|
8041
|
+
nestedIndent = line.indent;
|
|
8042
|
+
continue;
|
|
8043
|
+
}
|
|
8044
|
+
const value = scalar(kv.value, line.no, errors);
|
|
8045
|
+
if (kv.key === "port") {
|
|
8046
|
+
const port = Number(value);
|
|
8047
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
8048
|
+
errors.push(`line ${line.no}: port must be an integer 1-65535, got \`${kv.value}\``);
|
|
8049
|
+
} else {
|
|
8050
|
+
current.port = port;
|
|
8051
|
+
}
|
|
8052
|
+
continue;
|
|
8053
|
+
}
|
|
8054
|
+
if (value === "") {
|
|
8055
|
+
errors.push(`line ${line.no}: \`${kv.key}\` needs a value`);
|
|
8056
|
+
continue;
|
|
8057
|
+
}
|
|
8058
|
+
if (kv.key === "cwd" && (value.startsWith("/") || value.includes(".."))) {
|
|
8059
|
+
errors.push(`line ${line.no}: cwd must be a relative path under the drive root (no leading / and no ..)`);
|
|
8060
|
+
continue;
|
|
8061
|
+
}
|
|
8062
|
+
current[kv.key] = value;
|
|
8063
|
+
}
|
|
8064
|
+
finish(current);
|
|
8065
|
+
if (version === null)
|
|
8066
|
+
errors.push("missing `version: 1`");
|
|
8067
|
+
if (services.length === 0 && errors.length === 0)
|
|
8068
|
+
errors.push("`services:` lists no entries");
|
|
8069
|
+
if (services.length > SERVICES_MAX)
|
|
8070
|
+
errors.push(`too many services (${services.length}) — the cap is ${SERVICES_MAX}`);
|
|
8071
|
+
const seen = new Set;
|
|
8072
|
+
for (const svc of services) {
|
|
8073
|
+
const label = svc.name || "<unnamed>";
|
|
8074
|
+
if (!SERVICE_NAME_RE.test(svc.name)) {
|
|
8075
|
+
errors.push(`service \`${label}\`: name must match ${SERVICE_NAME_RE} (alphanumeric start, then [A-Za-z0-9._-], max 32)`);
|
|
8076
|
+
} else if (seen.has(svc.name)) {
|
|
8077
|
+
errors.push(`service \`${label}\`: duplicate name`);
|
|
8078
|
+
}
|
|
8079
|
+
seen.add(svc.name);
|
|
8080
|
+
if (!svc.command)
|
|
8081
|
+
errors.push(`service \`${label}\`: missing \`command\``);
|
|
8082
|
+
if (svc.health?.cmd && svc.health.http) {
|
|
8083
|
+
errors.push(`service \`${label}\`: health takes \`cmd\` OR \`http\`, not both`);
|
|
8084
|
+
}
|
|
8085
|
+
if (svc.health?.http && !/^https?:\/\//.test(svc.health.http)) {
|
|
8086
|
+
errors.push(`service \`${label}\`: health.http must be an http(s):// URL`);
|
|
8087
|
+
}
|
|
8088
|
+
}
|
|
8089
|
+
return errors.length > 0 ? { manifest: null, errors } : { manifest: { version: 1, services }, errors: [] };
|
|
8090
|
+
}
|
|
8091
|
+
function emitScalar(value) {
|
|
8092
|
+
const needsQuote = value !== value.trim() || value.startsWith("'") || value.startsWith('"') || value === "|" || value === ">" || value.startsWith("| ") || value.startsWith("> ");
|
|
8093
|
+
if (!needsQuote)
|
|
8094
|
+
return value;
|
|
8095
|
+
return `"${value.replace(/([\\"])/g, "\\$1")}"`;
|
|
8096
|
+
}
|
|
8097
|
+
function renderServicesManifest(manifest) {
|
|
8098
|
+
const out = [
|
|
8099
|
+
"# Durable services manifest — restored automatically after a machine recycle.",
|
|
8100
|
+
"# Docs: agentc services --help",
|
|
8101
|
+
"version: 1",
|
|
8102
|
+
"services:"
|
|
8103
|
+
];
|
|
8104
|
+
for (const svc of manifest.services) {
|
|
8105
|
+
out.push(` - name: ${emitScalar(svc.name)}`);
|
|
8106
|
+
out.push(` command: ${emitScalar(svc.command)}`);
|
|
8107
|
+
if (svc.cwd)
|
|
8108
|
+
out.push(` cwd: ${emitScalar(svc.cwd)}`);
|
|
8109
|
+
if (svc.port !== undefined)
|
|
8110
|
+
out.push(` port: ${svc.port}`);
|
|
8111
|
+
if (svc.env)
|
|
8112
|
+
out.push(` env: ${emitScalar(svc.env)}`);
|
|
8113
|
+
if (svc.setup)
|
|
8114
|
+
out.push(` setup: ${emitScalar(svc.setup)}`);
|
|
8115
|
+
if (svc.health && (svc.health.cmd || svc.health.http)) {
|
|
8116
|
+
out.push(" health:");
|
|
8117
|
+
if (svc.health.cmd)
|
|
8118
|
+
out.push(` cmd: ${emitScalar(svc.health.cmd)}`);
|
|
8119
|
+
if (svc.health.http)
|
|
8120
|
+
out.push(` http: ${emitScalar(svc.health.http)}`);
|
|
8121
|
+
}
|
|
8122
|
+
if (svc.data && (svc.data.dump || svc.data.restore)) {
|
|
8123
|
+
out.push(" data:");
|
|
8124
|
+
if (svc.data.dump)
|
|
8125
|
+
out.push(` dump: ${emitScalar(svc.data.dump)}`);
|
|
8126
|
+
if (svc.data.restore)
|
|
8127
|
+
out.push(` restore: ${emitScalar(svc.data.restore)}`);
|
|
8128
|
+
}
|
|
8129
|
+
}
|
|
8130
|
+
return out.join(`
|
|
8131
|
+
`) + `
|
|
8132
|
+
`;
|
|
8133
|
+
}
|
|
8134
|
+
// src/agent/services-restore.ts
|
|
8135
|
+
var SERVICES_STATE_DIR = "/tmp/ac-services";
|
|
8136
|
+
var SERVICE_STATUS_PREFIX = "##ac-service";
|
|
8137
|
+
var SERVICE_HEALTH_TIMEOUT_S = 45;
|
|
8138
|
+
function shq(s) {
|
|
8139
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
8140
|
+
}
|
|
8141
|
+
function shc(cmd) {
|
|
8142
|
+
return `sh -c ${shq(cmd)}`;
|
|
8143
|
+
}
|
|
8144
|
+
function serviceCwd(driveRoot, svc) {
|
|
8145
|
+
return svc.cwd ? `${driveRoot.replace(/\/+$/, "")}/${svc.cwd}` : driveRoot;
|
|
8146
|
+
}
|
|
8147
|
+
function restoreOneService(driveRoot, svc) {
|
|
8148
|
+
const n = svc.name;
|
|
8149
|
+
const cwd = serviceCwd(driveRoot, svc);
|
|
8150
|
+
const lines = [
|
|
8151
|
+
``,
|
|
8152
|
+
`# --- ${n} ---`,
|
|
8153
|
+
`(`,
|
|
8154
|
+
` cd ${shq(cwd)} || { status ${shq(n)} failed cwd-missing; exit 0; }`
|
|
8155
|
+
];
|
|
8156
|
+
if (svc.setup) {
|
|
8157
|
+
lines.push(` if ! ${shc(svc.setup)} >> "$D/${n}.setup.log" 2>&1; then`, ` status ${shq(n)} failed setup; exit 0`, ` fi`);
|
|
8158
|
+
}
|
|
8159
|
+
if (svc.data?.restore) {
|
|
8160
|
+
lines.push(` if ! ${shc(svc.data.restore)} >> "$D/${n}.setup.log" 2>&1; then`, ` status ${shq(n)} failed data-restore; exit 0`, ` fi`);
|
|
8161
|
+
}
|
|
8162
|
+
lines.push(` $SETSID ${shc(svc.command)} >> "$D/${n}.log" 2>&1 < /dev/null &`, ` echo $! > "$D/${n}.pid"`);
|
|
8163
|
+
const health = svc.health?.cmd ? shc(svc.health.cmd) : svc.health?.http ? `curl -fsS -m 2 -o /dev/null ${shq(svc.health.http)}` : null;
|
|
8164
|
+
if (health) {
|
|
8165
|
+
lines.push(` i=0`, ` while [ "$i" -lt ${SERVICE_HEALTH_TIMEOUT_S} ]; do`, ` if ${health} >/dev/null 2>&1; then status ${shq(n)} ok ''; exit 0; fi`, ` kill -0 "$(cat "$D/${n}.pid")" 2>/dev/null || { status ${shq(n)} failed exited; exit 0; }`, ` i=$((i+1)); sleep 1`, ` done`, ` status ${shq(n)} failed health-timeout`);
|
|
8166
|
+
} else {
|
|
8167
|
+
lines.push(` sleep 1`, ` if kill -0 "$(cat "$D/${n}.pid")" 2>/dev/null; then status ${shq(n)} unchecked ''; else status ${shq(n)} failed exited; fi`);
|
|
8168
|
+
}
|
|
8169
|
+
lines.push(`)`);
|
|
8170
|
+
return lines;
|
|
8171
|
+
}
|
|
8172
|
+
function servicesRestoreScript(manifest, driveRoot) {
|
|
8173
|
+
const head = [
|
|
8174
|
+
`#!/bin/sh`,
|
|
8175
|
+
`# generated by agent-compose — restores services from ${driveRoot}/.ac/services.yml`,
|
|
8176
|
+
`# logs + pidfiles: ${SERVICES_STATE_DIR}/<name>.{log,pid}`,
|
|
8177
|
+
`D=${shq(SERVICES_STATE_DIR)}`,
|
|
8178
|
+
`mkdir -p "$D"`,
|
|
8179
|
+
`status() { printf '%s %s %s %s\\n' ${shq(SERVICE_STATUS_PREFIX)} "$1" "$2" "$3"; }`,
|
|
8180
|
+
`# setsid detaches from the boot shell's session (Linux guests); on hosts`,
|
|
8181
|
+
`# without it (macOS running \`agentc services restore\`) plain & still works.`,
|
|
8182
|
+
`SETSID=""`,
|
|
8183
|
+
`command -v setsid >/dev/null 2>&1 && SETSID=setsid`
|
|
8184
|
+
];
|
|
8185
|
+
const body = manifest.services.flatMap((svc) => restoreOneService(driveRoot, svc));
|
|
8186
|
+
return [...head, ...body, ``].join(`
|
|
8187
|
+
`);
|
|
8188
|
+
}
|
|
8189
|
+
function servicesDumpScript(manifest, driveRoot) {
|
|
8190
|
+
const withDump = manifest.services.filter((svc) => svc.data?.dump);
|
|
8191
|
+
if (withDump.length === 0)
|
|
8192
|
+
return null;
|
|
8193
|
+
const lines = [
|
|
8194
|
+
`#!/bin/sh`,
|
|
8195
|
+
`# generated by agent-compose — pre-recycle data dumps from .ac/services.yml`,
|
|
8196
|
+
`status() { printf '%s %s %s %s\\n' ${shq(SERVICE_STATUS_PREFIX)} "$1" "$2" "$3"; }`
|
|
8197
|
+
];
|
|
8198
|
+
for (const svc of withDump) {
|
|
8199
|
+
const cwd = serviceCwd(driveRoot, svc);
|
|
8200
|
+
lines.push(``, `# --- ${svc.name} ---`, `(`, ` cd ${shq(cwd)} || { status ${shq(svc.name)} dump-failed cwd-missing; exit 0; }`, ` if ${shc(svc.data.dump)} > /dev/null 2>&1; then`, ` status ${shq(svc.name)} dumped ''`, ` else`, ` status ${shq(svc.name)} dump-failed ''`, ` fi`, `)`);
|
|
8201
|
+
}
|
|
8202
|
+
lines.push(``);
|
|
8203
|
+
return lines.join(`
|
|
8204
|
+
`);
|
|
8205
|
+
}
|
|
8206
|
+
function parseServicesRestoreOutput(stdout) {
|
|
8207
|
+
const out = [];
|
|
8208
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
8209
|
+
const line = raw.trim();
|
|
8210
|
+
if (!line.startsWith(`${SERVICE_STATUS_PREFIX} `))
|
|
8211
|
+
continue;
|
|
8212
|
+
const rest = line.slice(SERVICE_STATUS_PREFIX.length + 1).trim();
|
|
8213
|
+
const [name, status, ...detail] = rest.split(/\s+/);
|
|
8214
|
+
if (!name || !status)
|
|
8215
|
+
continue;
|
|
8216
|
+
if (!["ok", "unchecked", "failed", "dumped", "dump-failed"].includes(status))
|
|
8217
|
+
continue;
|
|
8218
|
+
const d = detail.join(" ").trim();
|
|
8219
|
+
out.push({ name, status, ...d ? { detail: d } : {} });
|
|
8220
|
+
}
|
|
8221
|
+
return out;
|
|
8222
|
+
}
|
|
8223
|
+
function formatServicesRestoreSummary(outcomes) {
|
|
8224
|
+
return outcomes.map((o) => {
|
|
8225
|
+
if (o.status === "ok" || o.status === "unchecked" || o.status === "dumped")
|
|
8226
|
+
return `${o.name} ✓`;
|
|
8227
|
+
const why = o.detail ? `${o.detail} — see ${SERVICES_STATE_DIR}/${o.name}.log` : `see ${SERVICES_STATE_DIR}/${o.name}.log`;
|
|
8228
|
+
return `${o.name} ✗ (${why})`;
|
|
8229
|
+
}).join(" ");
|
|
8230
|
+
}
|
|
7002
8231
|
// src/display.ts
|
|
7003
8232
|
var DISPLAY_MARKER_KEY = "acDisplay";
|
|
7004
8233
|
var DISPLAY_MARKER_VERSION = "v1";
|
|
@@ -7192,6 +8421,7 @@ function clampAskOptions(raw) {
|
|
|
7192
8421
|
}
|
|
7193
8422
|
return options;
|
|
7194
8423
|
}
|
|
8424
|
+
var DESKTOP_NOTE_MAX_CHARS = 200;
|
|
7195
8425
|
function serializeDisplayMarker(marker) {
|
|
7196
8426
|
return JSON.stringify({ [DISPLAY_MARKER_KEY]: DISPLAY_MARKER_VERSION, ...marker });
|
|
7197
8427
|
}
|
|
@@ -7277,6 +8507,21 @@ function parseDisplayMarker(line) {
|
|
|
7277
8507
|
...yLabel !== undefined ? { yLabel } : {}
|
|
7278
8508
|
};
|
|
7279
8509
|
}
|
|
8510
|
+
case "desktop": {
|
|
8511
|
+
const path = str("path");
|
|
8512
|
+
if (!path || !isPlausibleDrivePath(path))
|
|
8513
|
+
return null;
|
|
8514
|
+
const capturedAt = str("capturedAt");
|
|
8515
|
+
const capturedOk = capturedAt !== undefined && Number.isFinite(Date.parse(capturedAt));
|
|
8516
|
+
const note = str("note");
|
|
8517
|
+
return {
|
|
8518
|
+
kind: "desktop",
|
|
8519
|
+
path,
|
|
8520
|
+
...str("factorySlug") ? { factorySlug: str("factorySlug") } : {},
|
|
8521
|
+
...capturedOk ? { capturedAt } : {},
|
|
8522
|
+
...note !== undefined ? { note: note.slice(0, DESKTOP_NOTE_MAX_CHARS) } : {}
|
|
8523
|
+
};
|
|
8524
|
+
}
|
|
7280
8525
|
case "ask": {
|
|
7281
8526
|
const askId = str("askId");
|
|
7282
8527
|
const rawPrompt = str("prompt");
|
|
@@ -7359,7 +8604,8 @@ var VALUE_FLAGS = new Set([
|
|
|
7359
8604
|
"--to",
|
|
7360
8605
|
"--prompt",
|
|
7361
8606
|
"--options",
|
|
7362
|
-
"--approver"
|
|
8607
|
+
"--approver",
|
|
8608
|
+
"--note"
|
|
7363
8609
|
]);
|
|
7364
8610
|
function isShellControlWord(w) {
|
|
7365
8611
|
if (/^\d*>&\d+$/.test(w))
|
|
@@ -7421,6 +8667,8 @@ function detectAgentcInvocation(command) {
|
|
|
7421
8667
|
const path = drivePath(args[2]);
|
|
7422
8668
|
return path ? { verb: "display-image", path, ...withSlug } : null;
|
|
7423
8669
|
}
|
|
8670
|
+
case "desktop":
|
|
8671
|
+
return { verb: "display-desktop" };
|
|
7424
8672
|
case "preview": {
|
|
7425
8673
|
const path = drivePath(args[2]);
|
|
7426
8674
|
return path ? { verb: "display-preview", path } : null;
|
|
@@ -7574,6 +8822,8 @@ function createDisplayPromoter(ctx = {}) {
|
|
|
7574
8822
|
return call("display_plan", {});
|
|
7575
8823
|
case "display-image":
|
|
7576
8824
|
return call("display_image", { path: invocation.path, ...factoryRef(invocation.factorySlug) });
|
|
8825
|
+
case "display-desktop":
|
|
8826
|
+
return call("display_desktop", {});
|
|
7577
8827
|
case "display-preview":
|
|
7578
8828
|
return call("display_preview", { path: invocation.path, ...factoryRef(undefined) });
|
|
7579
8829
|
case "display-table":
|
|
@@ -7667,6 +8917,17 @@ function createDisplayPromoter(ctx = {}) {
|
|
|
7667
8917
|
...factoryRef(m?.factorySlug ?? inv.factorySlug)
|
|
7668
8918
|
})];
|
|
7669
8919
|
}
|
|
8920
|
+
case "display-desktop": {
|
|
8921
|
+
const marker = findDisplayMarker(text);
|
|
8922
|
+
if (marker?.kind !== "desktop")
|
|
8923
|
+
return [result(msg.output)];
|
|
8924
|
+
return [result({
|
|
8925
|
+
path: marker.path,
|
|
8926
|
+
...factoryRef(marker.factorySlug),
|
|
8927
|
+
capturedAt: marker.capturedAt ?? null,
|
|
8928
|
+
note: marker.note ?? null
|
|
8929
|
+
})];
|
|
8930
|
+
}
|
|
7670
8931
|
case "display-table": {
|
|
7671
8932
|
if (inv.source === "file")
|
|
7672
8933
|
return [result(msg.output)];
|