@vellumai/assistant 0.12.2-staging.6 → 0.12.2-staging.7

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 (120) hide show
  1. package/docs/architecture/memory.md +11 -2
  2. package/docs/desktop-browser-cli.md +4 -2
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  4. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  6. package/openapi.yaml +6 -2
  7. package/package.json +2 -2
  8. package/scripts/postinstall.ts +33 -0
  9. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  10. package/src/__tests__/agent-loop.test.ts +124 -0
  11. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  12. package/src/__tests__/channel-approval.test.ts +9 -14
  13. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  14. package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
  15. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  16. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  17. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  18. package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
  19. package/src/__tests__/terminal-tools.test.ts +8 -0
  20. package/src/__tests__/unicode.test.ts +36 -0
  21. package/src/agent/loop.ts +19 -0
  22. package/src/api/events/desktop-activity-changed.ts +10 -0
  23. package/src/api/index.ts +6 -0
  24. package/src/approvals/approval-primitive.ts +5 -2
  25. package/src/approvals/scoped-approval-grants.ts +6 -2
  26. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  27. package/src/daemon/conversation-agent-loop.ts +2 -0
  28. package/src/daemon/conversation-tool-setup.ts +57 -1
  29. package/src/daemon/conversation.ts +17 -0
  30. package/src/daemon/daemon-control.ts +2 -6
  31. package/src/daemon/orphan-reaper.ts +4 -3
  32. package/src/daemon/tool-setup-types.ts +6 -0
  33. package/src/daemon/wake-conversation-ops.ts +38 -15
  34. package/src/desktop/desktop-automation-lease.test.ts +143 -0
  35. package/src/desktop/desktop-automation-lease.ts +39 -3
  36. package/src/messaging/provider-message-metadata.ts +3 -3
  37. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  38. package/src/notifications/copy-composer.ts +11 -3
  39. package/src/persistence/conversation-plugin-facade.ts +13 -0
  40. package/src/persistence/conversation-tool-surface.ts +86 -0
  41. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  42. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  43. package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
  44. package/src/persistence/schema/index.ts +1 -0
  45. package/src/persistence/steps.ts +2 -0
  46. package/src/plugin-api/conversation-turn.ts +31 -7
  47. package/src/plugin-api/index.ts +9 -1
  48. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  49. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  50. package/src/plugins/defaults/memory/AGENTS.md +14 -2
  51. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
  52. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  53. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  54. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
  55. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  56. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  57. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  58. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  59. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  60. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  61. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  62. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  63. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  64. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  65. package/src/plugins/defaults/memory/injectors.ts +4 -3
  66. package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
  67. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  68. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  69. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  70. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  71. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  72. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  73. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  74. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  75. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  76. package/src/plugins/defaults/memory/tools.ts +1 -1
  77. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  78. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  79. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  80. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  81. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  82. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  83. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  84. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  85. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
  86. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  87. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  88. package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
  89. package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
  90. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  91. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
  92. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  93. package/src/runtime/AGENTS.md +1 -1
  94. package/src/runtime/__tests__/agent-wake.test.ts +86 -1
  95. package/src/runtime/agent-wake.ts +20 -4
  96. package/src/runtime/guardian-action-service.ts +2 -17
  97. package/src/runtime/guardian-reply-router.ts +1 -8
  98. package/src/runtime/routes/channel-route-shared.ts +1 -9
  99. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  100. package/src/runtime/routes/desktop-setup-routes.ts +7 -3
  101. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  102. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  103. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  104. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  105. package/src/schedule/run-script.ts +2 -2
  106. package/src/tools/host-terminal/host-shell.ts +12 -6
  107. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  108. package/src/tools/shared/shell-output.test.ts +10 -0
  109. package/src/tools/shared/shell-output.ts +14 -2
  110. package/src/tools/skills/sandbox-runner.ts +13 -2
  111. package/src/tools/skills/scaffold-managed.ts +2 -1
  112. package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
  113. package/src/tools/terminal/safe-env.ts +30 -1
  114. package/src/tools/terminal/sanitized-bash.ts +15 -2
  115. package/src/tools/terminal/shell-launch.test.ts +162 -0
  116. package/src/tools/terminal/shell.test.ts +29 -0
  117. package/src/tools/terminal/shell.ts +13 -7
  118. package/src/util/host-process.test.ts +17 -1
  119. package/src/util/host-process.ts +24 -0
  120. package/src/util/unicode.ts +29 -0
@@ -1,4 +1,5 @@
1
- import { safeStringSlice } from "../host-utils.js";
1
+ import { safeStringSlice } from "@vellumai/plugin-api";
2
+
2
3
  import type { Section, SectionIndex, Slug } from "./types.js";
3
4
 
4
5
  /**
@@ -32,6 +32,7 @@ import {
32
32
  getMessages,
33
33
  listInstalledSkills,
34
34
  parseMessageMetadata,
35
+ safeStringSlice,
35
36
  stringifyMessageContent,
36
37
  VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
37
38
  } from "@vellumai/plugin-api";
@@ -44,7 +45,7 @@ import {
44
45
  timeLatencySubSpan,
45
46
  } from "../../../../daemon/turn-latency-sub-spans.js";
46
47
  import { enqueueMemoryJob } from "../../../../persistence/jobs-store.js";
47
- import { safeStringSlice, stripCommentLines } from "../host-utils.js";
48
+ import { stripCommentLines } from "../host-utils.js";
48
49
  import { getLogger } from "../logging.js";
49
50
  import { type MemorySqlite, memorySqliteOrNull } from "../memory-db.js";
50
51
  import { getWorkspaceDir, getWorkspacePromptPath } from "../paths.js";
@@ -635,8 +636,16 @@ interface SelectionRow {
635
636
  * The row holds one section per slug: the selection's FIRST selected section
636
637
  * (pool order) stands for the page; a page selected with no section (a card,
637
638
  * or a section-less edge or learned line) logs none.
639
+ *
640
+ * A turn whose selector could not run (`selectorFailure`) logs no rows: its
641
+ * selections are the stable prefix kept unjudged, and the selection table is
642
+ * what the hot set's frecency and the learned-edge graph read as judgments.
643
+ * The pool record still carries the turn for the inspector.
638
644
  */
639
645
  export function attributeSelections(result: OrchestrateResult): SelectionRow[] {
646
+ if (result.selectorFailure) {
647
+ return [];
648
+ }
640
649
  const core = new Set<Slug>(result.lanes.core);
641
650
  const hot = new Set<Slug>(result.lanes.hot);
642
651
  const fresh = new Set<Slug>(result.lanes.fresh);
@@ -6,52 +6,7 @@
6
6
  * This module is side-effect free: importing it does not register any plugin.
7
7
  */
8
8
 
9
- const HIGH_SURROGATE_START = 0xd800;
10
- const HIGH_SURROGATE_END = 0xdbff;
11
- const LOW_SURROGATE_START = 0xdc00;
12
- const LOW_SURROGATE_END = 0xdfff;
13
-
14
- function isHighSurrogate(code: number): boolean {
15
- return code >= HIGH_SURROGATE_START && code <= HIGH_SURROGATE_END;
16
- }
17
-
18
- function isLowSurrogate(code: number): boolean {
19
- return code >= LOW_SURROGATE_START && code <= LOW_SURROGATE_END;
20
- }
21
-
22
- /**
23
- * Slice a string by code-unit indices without splitting a surrogate pair, so
24
- * the result never ends or begins mid-emoji. Indices are clamped to the
25
- * string bounds; a cut that would land between a high/low surrogate is nudged
26
- * inward to the nearest whole code point.
27
- */
28
- function safeStringSlice(
29
- str: string,
30
- start = 0,
31
- end: number = str.length,
32
- ): string {
33
- let safeStart = Math.max(0, Math.min(str.length, start));
34
- let safeEnd = Math.max(safeStart, Math.min(str.length, end));
35
-
36
- if (safeEnd < str.length && safeEnd > safeStart) {
37
- const lastCode = str.charCodeAt(safeEnd - 1);
38
- if (isHighSurrogate(lastCode)) {
39
- safeEnd--;
40
- }
41
- }
42
-
43
- if (safeStart > 0 && safeStart < str.length) {
44
- const firstCode = str.charCodeAt(safeStart);
45
- if (isLowSurrogate(firstCode)) {
46
- safeStart++;
47
- if (safeStart > safeEnd) {
48
- safeEnd = safeStart;
49
- }
50
- }
51
- }
52
-
53
- return str.slice(safeStart, safeEnd);
54
- }
9
+ import { safeStringSlice } from "@vellumai/plugin-api";
55
10
 
56
11
  /**
57
12
  * Minimum number of characters to preserve when truncating.
@@ -303,4 +303,4 @@ The provider-level rate limiter (`providers/ratelimit.ts`) also logs warnings (m
303
303
 
304
304
  HTTP is the sole transport for client-daemon communication. The runtime HTTP server (`assistant/src/runtime/http-server.ts`) is the canonical API surface. Clients connect via HTTP for request/response operations and SSE (`GET /v1/events`) for streaming server-to-client events.
305
305
 
306
- When writing skills that need to call daemon configuration endpoints, use `curl` with the runtime HTTP API (JWT-authenticated via `Authorization: Bearer <jwt>`). The assistant already knows how to use `curl`.
306
+ A skill never calls the runtime HTTP API directly: every request from a skill targets the gateway, and `gateway-only-guard.test.ts` fails CI on a runtime-port URL in a skill. For configuration reads, use the CLI (`assistant config get`); for control-plane writes with no CLI verb, use `$INTERNAL_GATEWAY_BASE_URL`. See "Gateway-Only API Consumption" in `gateway/AGENTS.md` for the rule and its exceptions.
@@ -18,9 +18,10 @@
18
18
  * route captured calls back to the originating conversation's probe arrays.
19
19
  */
20
20
 
21
- import { beforeEach, describe, expect, mock, test } from "bun:test";
21
+ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
22
22
 
23
23
  import type { DiskPressureStatus } from "../../daemon/disk-pressure-guard.js";
24
+ import { desktopAutomationLease } from "../../desktop/desktop-automation-lease.js";
24
25
 
25
26
  // ── Per-conversation capture registry ────────────────────────────────
26
27
  //
@@ -501,6 +502,13 @@ function makeWakeConversation(options: {
501
502
  `tools:${snapshotAllowedTools()?.join(",") ?? "all"}`,
502
503
  );
503
504
  },
505
+ // Mirrors Conversation.setPreactivatedSkillIds; the wake's tool-scope
506
+ // restore calls it, and a double without it throws inside the restore
507
+ // closure, leaving every field restored after it untouched.
508
+ preactivatedSkillIds: undefined as string[] | undefined,
509
+ setPreactivatedSkillIds(ids: string[] | undefined) {
510
+ this.preactivatedSkillIds = ids;
511
+ },
504
512
  get wakePersonaOverride() {
505
513
  return wakePersonaOverride;
506
514
  },
@@ -1357,6 +1365,42 @@ describe("wakeAgentForOpportunity", () => {
1357
1365
  expect(conversation.toolContextPin).toBeUndefined();
1358
1366
  });
1359
1367
 
1368
+ test("applies wireToolDefinitions alongside the allowlist and restores it after the wake", async () => {
1369
+ const replay = [
1370
+ { name: "remember", description: "Save", input_schema: {} },
1371
+ { name: "bell_jingle", description: "Ring", input_schema: {} },
1372
+ ];
1373
+ let replayDuringRun: unknown;
1374
+ const conversation = makeWakeConversation({
1375
+ runImpl: async (input) => {
1376
+ replayDuringRun = conversation.wireToolReplay;
1377
+ return runResult([
1378
+ ...input,
1379
+ { role: "assistant", content: [{ type: "text", text: "Saved." }] },
1380
+ ]);
1381
+ },
1382
+ });
1383
+
1384
+ const result = await wakeAgentForOpportunity(
1385
+ {
1386
+ conversationId: conversation.conversationId,
1387
+ hint: "review for memories",
1388
+ source: "memory-retrospective",
1389
+ allowedTools: ["remember"],
1390
+ toolGateMode: "execution",
1391
+ wireToolDefinitions: replay,
1392
+ },
1393
+ { resolveTarget: async () => conversation },
1394
+ );
1395
+
1396
+ expect(result).toEqual({ invoked: true, producedToolCalls: false });
1397
+ // The replay array is live on the conversation for the duration of the
1398
+ // run (the tool resolver returns it as the wire array)...
1399
+ expect(replayDuringRun).toEqual(replay);
1400
+ // ...and cleared alongside the allowlist + gate mode after the wake.
1401
+ expect(conversation.wireToolReplay).toBeUndefined();
1402
+ });
1403
+
1360
1404
  test("defaults to the wire gate mode when toolGateMode is absent", async () => {
1361
1405
  let gateModeDuringRun: string | undefined;
1362
1406
  const conversation = makeWakeConversation({
@@ -1517,6 +1561,47 @@ describe("wakeAgentForOpportunity", () => {
1517
1561
  expect(conversation.pushedMessages[2]).toEqual(followupAssistant);
1518
1562
  });
1519
1563
 
1564
+ for (const fails of [false, true]) {
1565
+ test(`releases desktop control before a ${fails ? "failed" : "completed"} wake drains the queue`, async () => {
1566
+ const conversation = makeWakeConversation({
1567
+ scriptedAssistant: {
1568
+ role: "assistant",
1569
+ content: [{ type: "text", text: "reply" }],
1570
+ },
1571
+ ...(fails
1572
+ ? {
1573
+ runImpl: async () => {
1574
+ throw new Error("wake failed");
1575
+ },
1576
+ }
1577
+ : {}),
1578
+ });
1579
+ const release = spyOn(desktopAutomationLease, "releaseForConversation");
1580
+ const setProcessing = conversation.setProcessing.bind(conversation);
1581
+ conversation.setProcessing = (processing) => {
1582
+ if (!processing) {
1583
+ expect(release).toHaveBeenCalledWith(conversation.conversationId);
1584
+ }
1585
+ setProcessing(processing);
1586
+ };
1587
+ try {
1588
+ const result = await wakeAgentForOpportunity(
1589
+ {
1590
+ conversationId: conversation.conversationId,
1591
+ hint: "x",
1592
+ source: "unit-test",
1593
+ },
1594
+ { resolveTarget: async () => conversation },
1595
+ );
1596
+ expect(result.invoked).toBe(!fails);
1597
+ expect(release).toHaveBeenCalledTimes(1);
1598
+ expect(conversation.drainQueueCalls).toBe(1);
1599
+ } finally {
1600
+ release.mockRestore();
1601
+ }
1602
+ });
1603
+ }
1604
+
1520
1605
  test("marks processing true during the run and false afterwards", async () => {
1521
1606
  const conversation = makeWakeConversation({
1522
1607
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
@@ -107,6 +107,7 @@ import {
107
107
  persistWakeTriggerMessage,
108
108
  scopeWakeAllowedTools,
109
109
  } from "../daemon/wake-conversation-ops.js";
110
+ import { desktopAutomationLease } from "../desktop/desktop-automation-lease.js";
110
111
  import {
111
112
  recordCompactionEndBestEffort,
112
113
  recordCompactionStartBestEffort,
@@ -118,7 +119,7 @@ import {
118
119
  setAgentLoopExitReasonOnLatestLog,
119
120
  } from "../persistence/llm-request-log-store.js";
120
121
  import type { SystemPromptPersonaOverride } from "../prompts/system-prompt.js";
121
- import type { Message } from "../providers/types.js";
122
+ import type { Message, ToolDefinition } from "../providers/types.js";
122
123
  import {
123
124
  type UntrustedContentSource,
124
125
  wrapUntrustedContent,
@@ -350,6 +351,17 @@ export interface WakeOptions {
350
351
  * skill-management authoring tools callable directly.
351
352
  */
352
353
  preactivateSkillIds?: readonly string[];
354
+ /**
355
+ * Tool definitions to send verbatim as the wake's wire tool array, in place
356
+ * of the ones the conversation would resolve for itself. Used by fork-based
357
+ * memory retrospectives to replay the SOURCE conversation's recorded surface
358
+ * (`getConversationToolSurface`) so the provider prompt-cache prefix matches
359
+ * the source's live turns byte for byte. Definitions only: what may execute
360
+ * is still decided by `allowedTools` and the turn's own active set. Applied
361
+ * and restored alongside `allowedTools`; ignored when `allowedTools` is
362
+ * absent.
363
+ */
364
+ wireToolDefinitions?: readonly ToolDefinition[];
353
365
  /**
354
366
  * Explicit persona/channel slugs for the wake's system-prompt build,
355
367
  * applied to the conversation for the duration of the run and restored
@@ -1410,6 +1422,7 @@ export async function wakeAgentForOpportunity(
1410
1422
  * is one function rather than a rebuild bolted onto either half.
1411
1423
  */
1412
1424
  const restoreWakeTurnScope = (): void => {
1425
+ desktopAutomationLease.releaseForConversation(conversationId);
1413
1426
  restoreWakeAllowedTools();
1414
1427
  clearWakePersonaOverride();
1415
1428
  syncWakeLoopSystemPrompt();
@@ -1422,9 +1435,12 @@ export async function wakeAgentForOpportunity(
1422
1435
  restoreWakeToolScope = scopeWakeAllowedTools(
1423
1436
  conversation,
1424
1437
  new Set(opts.allowedTools),
1425
- opts.toolGateMode,
1426
- opts.toolContextPin,
1427
- opts.preactivateSkillIds,
1438
+ {
1439
+ gateMode: opts.toolGateMode,
1440
+ toolContextPin: opts.toolContextPin,
1441
+ preactivateSkillIds: opts.preactivateSkillIds,
1442
+ wireToolDefinitions: opts.wireToolDefinitions,
1443
+ },
1428
1444
  );
1429
1445
  return true;
1430
1446
  } catch (err) {
@@ -16,21 +16,6 @@ import {
16
16
  isApprovalAction,
17
17
  } from "./channel-approval-types.js";
18
18
 
19
- // ---------------------------------------------------------------------------
20
- // Constants
21
- // ---------------------------------------------------------------------------
22
-
23
- /**
24
- * Legacy actions that map to canonical ones during client rollout.
25
- * All temporal/persistent approval variants collapse to approve_once.
26
- * Keep until all clients are updated and no in-flight buttons remain.
27
- */
28
- const LEGACY_ACTION_MAP: Record<string, string> = {
29
- approve_10m: "approve_once",
30
- approve_conversation: "approve_once",
31
- approve_always: "approve_once",
32
- };
33
-
34
19
  // ---------------------------------------------------------------------------
35
20
  // Types
36
21
  // ---------------------------------------------------------------------------
@@ -98,8 +83,8 @@ export async function processGuardianDecision(
98
83
  ): Promise<ProcessGuardianDecisionResult> {
99
84
  const { requestId, conversationId, channel, actorContext } = params;
100
85
 
101
- // 1. Canonicalize legacy actions, then validate
102
- const action = LEGACY_ACTION_MAP[params.action] ?? params.action;
86
+ // 1. Validate the action
87
+ const action = params.action;
103
88
  if (!isApprovalAction(action)) {
104
89
  return {
105
90
  ok: false,
@@ -153,12 +153,6 @@ export interface GuardianReplyResult {
153
153
  // Callback data parser — format: "apr:<requestId>:<action>"
154
154
  // ---------------------------------------------------------------------------
155
155
 
156
- const LEGACY_CALLBACK_MAP: Record<string, string> = {
157
- approve_10m: "approve_once",
158
- approve_conversation: "approve_once",
159
- approve_always: "approve_once",
160
- };
161
-
162
156
  interface ParsedCallback {
163
157
  requestId: string;
164
158
  action: ApprovalAction;
@@ -170,8 +164,7 @@ function parseCallbackAction(data: string): ParsedCallback | null {
170
164
  return null;
171
165
  }
172
166
  const requestId = parts[1];
173
- const rawAction = parts.slice(2).join(":");
174
- const action = LEGACY_CALLBACK_MAP[rawAction] ?? rawAction;
167
+ const action = parts.slice(2).join(":");
175
168
  if (!requestId || !isApprovalAction(action)) {
176
169
  return null;
177
170
  }
@@ -29,13 +29,6 @@ export function requiredDecisionKeywords(
29
29
  // Callback data parser — format: "apr:<requestId>:<action>"
30
30
  // ---------------------------------------------------------------------------
31
31
 
32
- /** Map legacy callback actions to canonical ones for in-flight buttons. */
33
- const LEGACY_CALLBACK_MAP: Record<string, string> = {
34
- approve_10m: "approve_once",
35
- approve_conversation: "approve_once",
36
- approve_always: "approve_once",
37
- };
38
-
39
32
  export function parseCallbackData(
40
33
  data: string,
41
34
  sourceChannel?: string,
@@ -45,8 +38,7 @@ export function parseCallbackData(
45
38
  return null;
46
39
  }
47
40
  const requestId = parts[1];
48
- const rawAction = parts.slice(2).join(":");
49
- const action = LEGACY_CALLBACK_MAP[rawAction] ?? rawAction;
41
+ const action = parts.slice(2).join(":");
50
42
  if (!requestId || !isApprovalAction(action)) {
51
43
  return null;
52
44
  }
@@ -79,8 +79,8 @@ describe("desktop setup route feature gate", () => {
79
79
  setOverridesForTesting({ "assistant-desktop": true });
80
80
  expect(route.handler({})).toEqual(
81
81
  route.method === "GET"
82
- ? { state: "required" }
83
- : { state: "installing", stage: "packages" },
82
+ ? { state: "required", automationActive: false }
83
+ : { state: "installing", stage: "packages", automationActive: false },
84
84
  );
85
85
  expect(route.method === "GET" ? status : start).toHaveBeenCalledTimes(1);
86
86
  expect(route.method === "GET" ? start : status).not.toHaveBeenCalled();
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import { getConfig } from "../../config/loader.js";
4
+ import { desktopAutomationLease } from "../../desktop/desktop-automation-lease.js";
4
5
  import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
5
6
  import { isVirtualDesktopEnabled } from "../../desktop/virtual-desktop-feature.js";
6
7
  import { GATEWAY_PRINCIPALS } from "../auth/route-policy.js";
@@ -9,6 +10,7 @@ import type { RouteDefinition } from "./types.js";
9
10
 
10
11
  const statusSchema = z.object({
11
12
  state: z.enum(["required", "installing", "ready", "failed", "unsupported"]),
13
+ automationActive: z.boolean().optional(),
12
14
  stage: z.enum(["packages", "chrome", "checking"]).optional(),
13
15
  });
14
16
 
@@ -24,9 +26,11 @@ export const ROUTES: RouteDefinition[] = ["GET", "POST"].map((method) => ({
24
26
  "Virtual desktop is available only on enabled platform-hosted assistants",
25
27
  );
26
28
  }
27
- return method === "GET"
28
- ? desktopDependencyInstaller.getStatus()
29
- : desktopDependencyInstaller.start();
29
+ const status =
30
+ method === "GET"
31
+ ? desktopDependencyInstaller.getStatus()
32
+ : desktopDependencyInstaller.start();
33
+ return { ...status, automationActive: desktopAutomationLease.isActive };
30
34
  },
31
35
  summary:
32
36
  method === "GET"
@@ -299,6 +299,30 @@ export async function handleApprovalInterception(
299
299
 
300
300
  return { handled: true, type: "stale_ignored" };
301
301
  }
302
+
303
+ // An approval callback that names no approval action is a button press
304
+ // the daemon does not serve (an id outside the decision vocabulary, or an
305
+ // answer token aimed at a card this rail does not resolve). It is never
306
+ // text: the channel normalizers copy callback data into `content`, so
307
+ // letting it fall through would hand the raw callback to the
308
+ // conversational and natural-language parsers, which can read a token
309
+ // like `approve_always` as an approval. Consume it as a stale button.
310
+ if (callbackData.startsWith("apr:")) {
311
+ log.warn(
312
+ { conversationId, callbackData },
313
+ "Approval callback carries no approval action, ignoring stale button press",
314
+ );
315
+ if (approvalMessageId) {
316
+ editStaleApprovalMessage({
317
+ replyCallbackUrl,
318
+ chatId: conversationExternalId,
319
+ messageId: approvalMessageId,
320
+ assistantId,
321
+ conversationId,
322
+ });
323
+ }
324
+ return { handled: true, type: "stale_ignored" };
325
+ }
302
326
  }
303
327
 
304
328
  // ── Conversational approval engine for plain-text messages ──
@@ -1531,8 +1531,7 @@ export async function handleChannelInbound({
1531
1531
  sourceMetadata.account.length > 0
1532
1532
  ? sourceMetadata.account
1533
1533
  : undefined;
1534
- const slackBotMentioned =
1535
- sourceChannel === "slack" && sourceMetadata?.slackBotMentioned === true;
1534
+ const botMentioned = sourceMetadata?.botMentioned === true;
1536
1535
 
1537
1536
  // ── DM cold-start backfill ──
1538
1537
  // First time a Slack DM without thread_ts lands in a conversation that
@@ -1625,7 +1624,7 @@ export async function handleChannelInbound({
1625
1624
  approvalCopyGenerator,
1626
1625
  chatType: sourceChatType,
1627
1626
  clientTimezone: inboundClientTimezone,
1628
- slackBotMentioned,
1627
+ botMentioned,
1629
1628
  slackInbound,
1630
1629
  channelInbound,
1631
1630
  });
@@ -1169,7 +1169,7 @@ describe("channel activity timing", () => {
1169
1169
  externalChatId: channelId,
1170
1170
  trustCtx,
1171
1171
  metadataHints: [],
1172
- slackBotMentioned: true,
1172
+ botMentioned: true,
1173
1173
  replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&threadTs=${threadTs}`,
1174
1174
  });
1175
1175
 
@@ -105,8 +105,11 @@ export interface BackgroundProcessingParams {
105
105
  chatType?: string;
106
106
  /** IANA timezone reported by the active client for the current turn. */
107
107
  clientTimezone?: string;
108
- /** Slack app_mention/direct bot mention signal from the gateway. */
109
- slackBotMentioned?: boolean;
108
+ /**
109
+ * The message addresses the assistant by name, as stated by the channel
110
+ * (`sourceMetadata.botMentioned`). Absent means not established.
111
+ */
112
+ botMentioned?: boolean;
110
113
  /**
111
114
  * Slack-specific inbound metadata extracted at the HTTP boundary. Threaded
112
115
  * through to `persistUserMessage` so the row can be tagged with a
@@ -166,7 +169,7 @@ export function processChannelMessageInBackground(
166
169
  sourceLanguageCode,
167
170
  chatType,
168
171
  clientTimezone,
169
- slackBotMentioned,
172
+ botMentioned,
170
173
  slackInbound,
171
174
  channelInbound,
172
175
  slackReactionRowMeta,
@@ -204,7 +207,7 @@ export function processChannelMessageInBackground(
204
207
  initiatorUserId: slackInbound?.actorExternalUserId,
205
208
  startImmediately: shouldShowActivityImmediately({
206
209
  chatType,
207
- botMentioned: slackBotMentioned,
210
+ botMentioned,
208
211
  }),
209
212
  });
210
213
  const stopApprovalWatcher = replyCallbackUrl
@@ -1,6 +1,7 @@
1
1
  import { buildSanitizedEnv } from "../tools/terminal/safe-env.js";
2
2
  import {
3
3
  buildShellInvocation,
4
+ buildShellSpawnFlags,
4
5
  terminateProcessTree,
5
6
  } from "../util/host-process.js";
6
7
  import { getLogger } from "../util/logger.js";
@@ -64,10 +65,9 @@ export async function runScript(
64
65
  const shell = buildShellInvocation(command);
65
66
  const proc = Bun.spawn([shell.command, ...shell.args], {
66
67
  cwd,
67
- detached: true,
68
+ ...buildShellSpawnFlags(),
68
69
  stdout: "pipe",
69
70
  stderr: "pipe",
70
- windowsHide: true,
71
71
  env: {
72
72
  ...buildSanitizedEnv(),
73
73
  // __SCHEDULE_ID lets a saved command find its own dir; __SCHEDULE_RUN_ID
@@ -23,8 +23,10 @@ import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
23
23
  import { redactSecrets } from "../../security/secret-scanner.js";
24
24
  import {
25
25
  buildShellInvocation,
26
+ buildShellSpawnFlags,
26
27
  prependUniquePathEntries,
27
28
  terminateProcessTree,
29
+ watchShellProcessStart,
28
30
  } from "../../util/host-process.js";
29
31
  import { getLogger } from "../../util/logger.js";
30
32
  import type { CompletedBackgroundTool } from "../background-tool-registry.js";
@@ -455,9 +457,9 @@ export const hostShellTool = {
455
457
  cwd: workingDir,
456
458
  env: hostEnv,
457
459
  stdio: ["ignore", "pipe", "pipe"],
458
- detached: true,
459
- windowsHide: true,
460
+ ...buildShellSpawnFlags(),
460
461
  });
462
+ const launch = watchShellProcessStart(child);
461
463
 
462
464
  const collector = attachBoundedStdio(child);
463
465
  let timedOut = false;
@@ -483,7 +485,9 @@ export const hostShellTool = {
483
485
  }
484
486
  completed = true;
485
487
  clearTimeout(timer);
486
- const result = collector.format(code, timedOut, timeoutSec);
488
+ const result = collector.format(code, timedOut, timeoutSec, {
489
+ started: launch.didStart(),
490
+ });
487
491
  // Cancel takes precedence over the SIGKILL-induced error result.
488
492
  const status = aborted
489
493
  ? "cancelled"
@@ -629,9 +633,9 @@ export const hostShellTool = {
629
633
  cwd: workingDir,
630
634
  env: hostEnv,
631
635
  stdio: ["ignore", "pipe", "pipe"],
632
- detached: true,
633
- windowsHide: true,
636
+ ...buildShellSpawnFlags(),
634
637
  });
638
+ const launch = watchShellProcessStart(child);
635
639
  const collector = attachBoundedStdio(child, {
636
640
  onOutput: context.onOutput,
637
641
  });
@@ -657,7 +661,9 @@ export const hostShellTool = {
657
661
  clearTimeout(timer);
658
662
  context.signal?.removeEventListener("abort", onAbort);
659
663
 
660
- const result = collector.format(code, timedOut, timeoutSec);
664
+ const result = collector.format(code, timedOut, timeoutSec, {
665
+ started: launch.didStart(),
666
+ });
661
667
 
662
668
  resolve({
663
669
  content: result.content,
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { minimatch } from "minimatch";
5
5
 
6
6
  import { ensureDir, pathExists } from "../../../util/fs.js";
7
+ import { surrogateSafeWindow } from "../../../util/unicode.js";
7
8
  import { isAbortLikeError } from "../abort.js";
8
9
  import { applyEdit } from "./edit-engine.js";
9
10
  import * as Err from "./errors.js";
@@ -102,37 +103,6 @@ function truncationNotice(
102
103
  return `\n\n[Truncated: characters ${start}-${end} of ${totalChars}. Read on with start_index=${end}.]`;
103
104
  }
104
105
 
105
- const isHighSurrogate = (code: number): boolean =>
106
- code >= 0xd800 && code <= 0xdbff;
107
- const isLowSurrogate = (code: number): boolean =>
108
- code >= 0xdc00 && code <= 0xdfff;
109
-
110
- /**
111
- * Character window that never splits a surrogate pair. A split leaves a lone
112
- * half at each edge, and each encodes to U+FFFD, so the character is lost from
113
- * both this window and the next one paged in after it.
114
- */
115
- export function surrogateSafeWindow(
116
- total: number,
117
- charCodeAt: (index: number) => number,
118
- requestedStart: number,
119
- maxChars: number,
120
- ): { start: number; end: number } {
121
- let start = Math.max(0, Math.min(requestedStart, total));
122
- if (start > 0 && start < total && isLowSurrogate(charCodeAt(start))) {
123
- start -= 1;
124
- }
125
-
126
- let end = Math.min(total, start + maxChars);
127
- if (end > start && end < total && isHighSurrogate(charCodeAt(end - 1))) {
128
- // Backing off would empty a one-character window, which stalls paging on
129
- // the same offset, so take the whole pair instead.
130
- end = end - 1 > start ? end - 1 : Math.min(total, end + 1);
131
- }
132
-
133
- return { start, end };
134
- }
135
-
136
106
  export class FileSystemOps {
137
107
  private policy: PathPolicy;
138
108
  private sizeLimit: number | undefined;
@@ -7,6 +7,7 @@ import {
7
7
  formatShellOutput,
8
8
  MAX_OUTPUT_LENGTH,
9
9
  OUTPUT_TRUNCATED_TAG,
10
+ SHELL_DID_NOT_START_MESSAGE,
10
11
  } from "./shell-output.js";
11
12
 
12
13
  describe("BoundedStdioCollector", () => {
@@ -85,6 +86,15 @@ describe("attachBoundedStdio", () => {
85
86
  });
86
87
  });
87
88
 
89
+ describe("formatShellOutput launch start", () => {
90
+ test("does not report command_completed when the process never started", () => {
91
+ const result = formatShellOutput("", "", 0, false, 120, { started: false });
92
+ expect(result.isError).toBe(true);
93
+ expect(result.content).toBe(SHELL_DID_NOT_START_MESSAGE);
94
+ expect(result.content).not.toContain("<command_completed />");
95
+ });
96
+ });
97
+
88
98
  describe("formatShellOutput truncation", () => {
89
99
  test("truncates an already-materialized oversized string without a file path", () => {
90
100
  const longOutput = "x".repeat(30_000);