zcode-acp-server 0.47.9 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/dist/code-fingerprint.json +2 -2
  3. package/dist/config/workflow-gate.d.ts +97 -0
  4. package/dist/config/workflow-gate.d.ts.map +1 -0
  5. package/dist/config/workflow-gate.js +159 -0
  6. package/dist/config/workflow-gate.js.map +1 -0
  7. package/dist/handlers/background-tasks.d.ts.map +1 -1
  8. package/dist/handlers/background-tasks.js +56 -0
  9. package/dist/handlers/background-tasks.js.map +1 -1
  10. package/dist/handlers/dispatch.js +4 -0
  11. package/dist/handlers/dispatch.js.map +1 -1
  12. package/dist/handlers/session.d.ts +11 -0
  13. package/dist/handlers/session.d.ts.map +1 -1
  14. package/dist/handlers/session.js +109 -37
  15. package/dist/handlers/session.js.map +1 -1
  16. package/dist/handlers/slash.d.ts +7 -2
  17. package/dist/handlers/slash.d.ts.map +1 -1
  18. package/dist/handlers/slash.js +111 -4
  19. package/dist/handlers/slash.js.map +1 -1
  20. package/dist/i18n.d.ts +2 -0
  21. package/dist/i18n.d.ts.map +1 -1
  22. package/dist/i18n.js +6 -0
  23. package/dist/i18n.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -3
  26. package/dist/index.js.map +1 -1
  27. package/dist/remote/session-close-endpoint.d.ts.map +1 -1
  28. package/dist/remote/session-close-endpoint.js +6 -0
  29. package/dist/remote/session-close-endpoint.js.map +1 -1
  30. package/dist/remote/settings-endpoint.d.ts.map +1 -1
  31. package/dist/remote/settings-endpoint.js +362 -3
  32. package/dist/remote/settings-endpoint.js.map +1 -1
  33. package/dist/server.d.ts +55 -0
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +87 -0
  36. package/dist/server.js.map +1 -1
  37. package/dist/settings/workflow.d.ts +139 -0
  38. package/dist/settings/workflow.d.ts.map +1 -0
  39. package/dist/settings/workflow.js +443 -0
  40. package/dist/settings/workflow.js.map +1 -0
  41. package/dist/translators/tool-helpers.d.ts.map +1 -1
  42. package/dist/translators/tool-helpers.js +4 -0
  43. package/dist/translators/tool-helpers.js.map +1 -1
  44. package/dist/utils.d.ts +9 -0
  45. package/dist/utils.d.ts.map +1 -1
  46. package/dist/utils.js +8 -0
  47. package/dist/utils.js.map +1 -1
  48. package/dist/workflow/poller.d.ts +68 -0
  49. package/dist/workflow/poller.d.ts.map +1 -0
  50. package/dist/workflow/poller.js +290 -0
  51. package/dist/workflow/poller.js.map +1 -0
  52. package/docs/BACKLOG.md +20 -2
  53. package/package.json +1 -1
@@ -24,13 +24,14 @@ import { buildProviderRegistry } from "../config/provider-registry.js";
24
24
  import { pushAccountProviderConfig } from "../config/account-provider.js";
25
25
  import { initialSessionMode } from "../config/settings.js";
26
26
  import { applyModelSwitch, buildResumeRuntimeModel } from "../config/runtime-model.js";
27
+ import { filterWorkflowCommands, rememberGate, workflowGateNow } from "../config/workflow-gate.js";
27
28
  import { messages } from "../i18n.js";
28
29
  import { lookupLazySession, lookupModelChoiceByZcodeSid, recordMaterializedSession, rememberLazySession, } from "../lazy-sessions.js";
29
30
  import { refreshTerminalTabTitle } from "../terminal-title.js";
30
31
  import { buildDiffContent, EventTranslator, extractLocations, formatTurnError, isBackendLostError, isTransientTurnError, ProjectionDiffer, } from "../translators/index.js";
31
32
  import { clientConnectionRoot, log, warn } from "../utils.js";
32
33
  import { dispatchEvent } from "./dispatch.js";
33
- import { emitSessionTurnState, sendSessionUpdate, sendTextChunk, withReplayBatch } from "./io.js";
34
+ import { emitSessionTurnState, sendAvailableCommandsDeferred, sendSessionUpdate, sendTextChunk, withReplayBatch, } from "./io.js";
34
35
  import { EDIT_DIFF_READ_LIMIT, fetchMessages, fetchMessagesSinceAnchor, fullSlice, readTailLimit, REPLY_READ_LIMIT, replayMessages, sliceTail, TURN_READ, } from "./replay.js";
35
36
  import { extractPermDeniedPath, extractSandboxDenial, GENERIC_HINT_KEY, handleSandboxDenial, READ_ONLY_TOOLS, } from "./sandbox-allow.js";
36
37
  import { handleServerRequests } from "./server-requests.js";
@@ -39,6 +40,60 @@ function workspaceFor(cwd) {
39
40
  const p = cwd || process.cwd();
40
41
  return { workspacePath: p, workspaceKey: p };
41
42
  }
43
+ /**
44
+ * Dynamic-workflow session flag (desktop-host parity, server.backendWorkflowGate):
45
+ * `{dynamicWorkflowEnabled:true}` rides every session/create·resume when the
46
+ * gate resolved enabled; otherwise NOTHING is written — the backend's zod
47
+ * schemas are strict, and the flag-absent default is exactly fail-closed.
48
+ * Awaiting the gate on the hot path is free (the verdict resolved at backend
49
+ * spawn); the cold-path bound is the gate fetch's 6s timeout, well under a
50
+ * backend boot. Fork and sub-agent sessions copy the flag inside the backend
51
+ * — no bridge-side injection point for those.
52
+ */
53
+ async function workflowFlag(server) {
54
+ const promise = server.backendWorkflowGate;
55
+ if (!promise)
56
+ return {};
57
+ const gate = await promise;
58
+ // Belt-and-braces vs captureGate (ensureBackend): an awaited verdict becomes
59
+ // synchronously readable immediately, so the send-time menu filter that runs
60
+ // right after create/resume sees it on its FIRST call.
61
+ rememberGate(promise, gate);
62
+ return gate.enabled ? { dynamicWorkflowEnabled: true } : {};
63
+ }
64
+ /**
65
+ * Cold-bridge menu catch-up: the `/` menu snapshot sent at session/new is
66
+ * filtered against the workflow gate, and on a COLD bridge (lazy session/new,
67
+ * no backend yet) that snapshot was taken while the gate was still
68
+ * pending/absent — the two workflow commands get dropped for that whole
69
+ * session. By the time a lazy session MATERIALIZES the gate has necessarily
70
+ * settled (create/resume params awaited it), so re-send the menu here.
71
+ * Overwrite semantics + the deferred helper's timer cancellation make this
72
+ * idempotent and correctly ordered (a late settle supersedes the stale send).
73
+ */
74
+ export function resendMenuAfterGateSettled(server, acpSid) {
75
+ if (!server.allCommands || !workflowGateNow(server)?.enabled)
76
+ return;
77
+ for (const sid of server.sessionAliases(acpSid)) {
78
+ sendAvailableCommandsDeferred(server.clients, sid, filterWorkflowCommands(server, server.allCommands));
79
+ }
80
+ }
81
+ /**
82
+ * Shared `session/resume` params builder: {sessionId, workspace} + optional
83
+ * session-lifetime mcpServers + the conditional dynamic-workflow flag. Every
84
+ * resume construction site (session/resume, /resume adoption, session/load,
85
+ * eviction/respawn reload) goes through here so the param shape cannot drift.
86
+ */
87
+ async function resumeParams(server, zcodeSid, cwd, mcpServers) {
88
+ const params = {
89
+ sessionId: zcodeSid,
90
+ workspace: workspaceFor(cwd),
91
+ ...(await workflowFlag(server)),
92
+ };
93
+ if (mcpServers)
94
+ params.mcpServers = mcpServers;
95
+ return params;
96
+ }
42
97
  /**
43
98
  * A client-supplied cwd is only ever trusted for `session/new` — creating a
44
99
  * session is the editor declaring its worktree. "/" is never a project root:
@@ -252,7 +307,10 @@ export async function newSession(server, params, client) {
252
307
  // Same banner handshake as boot-resume: the TUI's auto-submitted trigger
253
308
  // (DSH_TUI_AUTOPROMPT) drops martty's welcome banner so the window shows
254
309
  // the shared conversation instead of waiting for local input.
255
- if (isMarttyClient(server)) {
310
+ // Per-connection martty identity, NOT the sticky process flag: the phone
311
+ // claims this same bind below, and a sticky-gated arm re-pointed the
312
+ // handshake at the phone — the TUI's trigger then reached the model.
313
+ if (server.marttyConnectionRoots.has(bindRoot)) {
256
314
  server.bootResumeTriggerConnection = bindRoot;
257
315
  }
258
316
  const modes = await buildModes(server, null);
@@ -286,9 +344,12 @@ export async function newSession(server, params, client) {
286
344
  // into its transcript. The chunks sit BEHIND martty's welcome banner
287
345
  // until text is submitted — the DSH_TUI_AUTOPROMPT handshake armed
288
346
  // below drops the banner for the user.
289
- if (isMarttyClient(server)) {
347
+ if (server.marttyConnectionRoots.has(clientConnectionRoot(client))) {
290
348
  // Scope the handshake to THIS connection: a phone app attached to the
291
349
  // same bridge may prompt during the boot window and must not disarm it.
350
+ // Per-connection martty identity, NOT the sticky process flag — an
351
+ // attaching phone's session/new can win the race for the env; it must
352
+ // neither arm for itself nor leave a wrong arm for the TUI's trigger.
292
353
  server.bootResumeTriggerConnection = clientConnectionRoot(client);
293
354
  // Targeted replay — the booting TUI's own connection, never broadcast
294
355
  // (see the loadSession comment above).
@@ -504,6 +565,9 @@ export async function ensureRealSession(server, acpSid, opts = {}) {
504
565
  if (opts.ensureResident !== false) {
505
566
  await ensureBackendResident(server, acpSid, record.zcodeSid);
506
567
  }
568
+ // Materialization (resume flight) settled the gate — refresh a menu
569
+ // snapshot that may predate the verdict (see resendMenuAfterGateSettled).
570
+ resendMenuAfterGateSettled(server, acpSid);
507
571
  return record.zcodeSid;
508
572
  }
509
573
  if (record) {
@@ -544,6 +608,9 @@ export async function ensureRealSession(server, acpSid, opts = {}) {
544
608
  // ZCODE_ACP_MODE; the default stays "yolo" (unrestricted), which is
545
609
  // what the bridge always used.
546
610
  mode: initialSessionMode(),
611
+ // Dynamic-workflow enable rides create only when the gate says so —
612
+ // workflowFlag writes nothing otherwise (strict zod).
613
+ ...(await workflowFlag(server)),
547
614
  };
548
615
  if (pending.mcpServers && pending.mcpServers.length > 0) {
549
616
  createParams.mcpServers = pending.mcpServers;
@@ -592,6 +659,9 @@ export async function ensureRealSession(server, acpSid, opts = {}) {
592
659
  recordMaterializedSession(acpSid, sid, pending.cwd);
593
660
  log(`session/new ${acpSid} → created ${sid} (lazy, on first use)`);
594
661
  await server.ensureBackgroundListener(sid);
662
+ // Cold-bridge catch-up: the session/new menu snapshot was filtered on a
663
+ // pending gate; the create above settled it — re-send for this session.
664
+ resendMenuAfterGateSettled(server, acpSid);
595
665
  // Sync to the App's tasks-index.sqlite so the App UI shows this session.
596
666
  // Best-effort; failures are logged inside upsertSessionTask and swallowed.
597
667
  const { upsertSessionTask } = await import("../tasks-index.js");
@@ -806,18 +876,12 @@ export async function resumeSession(server, params, cx) {
806
876
  // the faithful resume fails outright). The params deliberately carry NO
807
877
  // apiKey either (the backend's schema rejects it; it resolves auth from
808
878
  // its own config/OAuth store).
809
- const zcParams = {
810
- sessionId: zcodeSid,
811
- workspace: workspaceFor(cwd),
812
- };
813
879
  // ACP session/resume may also carry mcpServers; the backend's resume
814
880
  // schema accepts the same array shape (verified: an unknown key would be
815
881
  // rejected before the session lookup). Remembered as session-lifetime
816
882
  // state so later reloads keep re-sending them (#193).
817
883
  rememberSessionMcpServers(server, acpSid, params.mcpServers);
818
- const storedMcp = server.sessionMcpServers.get(acpSid);
819
- if (storedMcp)
820
- zcParams.mcpServers = storedMcp;
884
+ const zcParams = await resumeParams(server, zcodeSid, cwd, server.sessionMcpServers.get(acpSid));
821
885
  // Push the provider registry BEFORE resume: a resumed session may carry a
822
886
  // third-party model in its history, and the backend needs the provider
823
887
  // registered to even process the resume turn.
@@ -949,14 +1013,9 @@ export async function resumeIntoSession(server, cx, acpSid, zcodeTarget) {
949
1013
  let settledHistory;
950
1014
  try {
951
1015
  await syncProviderRegistry(server, cwd);
952
- const outcome = await resumePreservingModel(server, {
953
- sessionId: zcodeTarget,
954
- workspace: workspaceFor(cwd),
955
- // Session-lifetime MCP set keeps riding every resume (#193).
956
- ...(server.sessionMcpServers.get(acpSid)
957
- ? { mcpServers: server.sessionMcpServers.get(acpSid) }
958
- : {}),
959
- });
1016
+ const outcome = await resumePreservingModel(server,
1017
+ // Session-lifetime MCP set keeps riding every resume (#193).
1018
+ await resumeParams(server, zcodeTarget, cwd, server.sessionMcpServers.get(acpSid)));
960
1019
  settledHistory = outcome.history;
961
1020
  server.markBackendLoaded(acpSid);
962
1021
  await repairUnavailableModel(server, zcodeTarget);
@@ -1026,16 +1085,10 @@ export async function loadSession(server, params, cx, opts = {}) {
1026
1085
  // snapshot is the mid-hydration-prefix guard; undefined = alreadyLive).
1027
1086
  let settledHistory;
1028
1087
  if (!alreadyLive) {
1029
- const zcParams = {
1030
- sessionId: zcodeSid,
1031
- workspace: workspaceFor(cwd),
1032
- };
1033
1088
  // Same mcpServers contract as resume: the client may re-declare them on
1034
1089
  // load; a stored set keeps riding along when it doesn't (#193).
1035
1090
  rememberSessionMcpServers(server, acpSid, params.mcpServers);
1036
- const storedMcp = server.sessionMcpServers.get(acpSid);
1037
- if (storedMcp)
1038
- zcParams.mcpServers = storedMcp;
1091
+ const zcParams = await resumeParams(server, zcodeSid, cwd, server.sessionMcpServers.get(acpSid));
1039
1092
  // Push the provider registry BEFORE resume: a loaded session may carry a
1040
1093
  // third-party model in its history, and the backend needs the provider
1041
1094
  // registered to process it.
@@ -1695,16 +1748,39 @@ async function runPrompt(server, params, cx, requestId, continuationRound = fals
1695
1748
  // connection submitting anything else first means the auto-submit was
1696
1749
  // lost — disarm so the trigger text typed manually later stays a normal
1697
1750
  // prompt.
1751
+ const promptRoot = clientConnectionRoot(client);
1752
+ const ackBootResumeTrigger = async () => {
1753
+ await sendTextChunk(cx, params.sessionId, messages().bootResumeAck, randomUUID());
1754
+ log("session/prompt: boot-resume banner handshake acknowledged");
1755
+ await emitSessionTurnState(server, params.sessionId, false, cx);
1756
+ return { stopReason: "end_turn" };
1757
+ };
1698
1758
  if (server.bootResumeTriggerConnection !== null &&
1699
- clientConnectionRoot(client) === server.bootResumeTriggerConnection) {
1759
+ promptRoot === server.bootResumeTriggerConnection) {
1700
1760
  server.bootResumeTriggerConnection = null;
1701
1761
  if (text === BOOT_RESUME_TRIGGER) {
1702
- await sendTextChunk(cx, params.sessionId, messages().bootResumeAck, randomUUID());
1703
- log("session/prompt: boot-resume banner handshake acknowledged");
1704
- await emitSessionTurnState(server, params.sessionId, false, cx);
1705
- return { stopReason: "end_turn" };
1706
- }
1707
- }
1762
+ return await ackBootResumeTrigger();
1763
+ }
1764
+ }
1765
+ else if (
1766
+ // Unarmed fallback: the boot-resume env was consumed by a NON-martty
1767
+ // first session/new (an attaching phone can win the race against the
1768
+ // TUI's boot), so no arm exists for the TUI — its auto-submitted trigger
1769
+ // still needs the ack or it reaches the model (observed from the mobile
1770
+ // app, 2026-09-23). A MARTTY connection's first prompt matching the
1771
+ // trigger verbatim is that handshake; a phone typing the same words is
1772
+ // real input (marttyConnectionRoots does not contain it) and goes to the
1773
+ // model as typed.
1774
+ server.bootResumeTriggerConnection === null &&
1775
+ text === BOOT_RESUME_TRIGGER &&
1776
+ server.marttyConnectionRoots.has(promptRoot) &&
1777
+ !server.connectionPromptSeen.has(promptRoot)) {
1778
+ return await ackBootResumeTrigger();
1779
+ }
1780
+ // First-prompt bookkeeping for the unarmed fallback above: only a martty
1781
+ // connection's FIRST prompt is a handshake candidate.
1782
+ if (promptRoot !== undefined)
1783
+ server.connectionPromptSeen.add(promptRoot);
1708
1784
  // Materialize a lazy session/new placeholder on first use. Placed after the
1709
1785
  // empty-prompt check so an invalid request doesn't create a backend session.
1710
1786
  const zcodeSid = await ensureRealSession(server, params.sessionId);
@@ -2429,17 +2505,13 @@ async function resumeBackendSession(server, zcParams) {
2429
2505
  */
2430
2506
  export async function reloadBackendSession(server, acpSid, zcodeSid) {
2431
2507
  const cwd = server.sessionCwds.get(acpSid) ?? process.cwd();
2432
- const zcParams = {
2433
- sessionId: zcodeSid,
2434
- workspace: workspaceFor(cwd),
2435
- };
2436
2508
  // Eviction/respawn reload: the backend lost the per-load mcpServers runtime
2437
2509
  // config with its resident session — re-send or the tools vanish (#193).
2438
2510
  const reloadMcp = server.sessionMcpServers.get(acpSid);
2439
2511
  if (reloadMcp) {
2440
- zcParams.mcpServers = reloadMcp;
2441
2512
  log(`session/resume (reload) carrying ${reloadMcp.length} client MCP server(s)`);
2442
2513
  }
2514
+ const zcParams = await resumeParams(server, zcodeSid, cwd, reloadMcp);
2443
2515
  // Same pre-resume steps as the ACP resume/load handlers: register the
2444
2516
  // provider registry (a resumed session's history references a model the
2445
2517
  // fresh backend can't process until its provider is registered — sends