@vellumai/assistant 0.12.2-staging.7 → 0.12.2

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 +2 -11
  2. package/docs/desktop-browser-cli.md +2 -4
  3. package/node_modules/@vellumai/environments/src/shell.test.ts +0 -21
  4. package/node_modules/@vellumai/environments/src/shell.ts +0 -24
  5. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +2 -8
  6. package/openapi.yaml +2 -6
  7. package/package.json +1 -1
  8. package/scripts/smoke-desktop-browser-cli.ts +0 -1
  9. package/src/__tests__/agent-loop.test.ts +0 -124
  10. package/src/__tests__/approval-interception-trust-gates.test.ts +0 -40
  11. package/src/__tests__/channel-approval.test.ts +14 -9
  12. package/src/__tests__/conversation-agent-loop.test.ts +0 -25
  13. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  14. package/src/__tests__/run-conversation-turn-persistence.test.ts +1 -138
  15. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  16. package/src/__tests__/subagent-tool-gate-mode.test.ts +0 -169
  17. package/src/__tests__/terminal-tools.test.ts +0 -8
  18. package/src/__tests__/unicode.test.ts +0 -36
  19. package/src/agent/loop.ts +0 -19
  20. package/src/api/index.ts +0 -6
  21. package/src/approvals/approval-primitive.ts +2 -5
  22. package/src/approvals/scoped-approval-grants.ts +2 -6
  23. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -43
  24. package/src/daemon/conversation-agent-loop.ts +0 -2
  25. package/src/daemon/conversation-tool-setup.ts +1 -57
  26. package/src/daemon/conversation.ts +0 -17
  27. package/src/daemon/daemon-control.ts +6 -2
  28. package/src/daemon/orphan-reaper.ts +3 -4
  29. package/src/daemon/tool-setup-types.ts +0 -6
  30. package/src/daemon/wake-conversation-ops.ts +15 -38
  31. package/src/desktop/desktop-automation-lease.test.ts +0 -143
  32. package/src/desktop/desktop-automation-lease.ts +3 -39
  33. package/src/messaging/provider-message-metadata.ts +3 -3
  34. package/src/notifications/__tests__/copy-composer.test.ts +0 -70
  35. package/src/notifications/copy-composer.ts +3 -11
  36. package/src/oauth/seed-providers.ts +30 -0
  37. package/src/persistence/conversation-plugin-facade.ts +0 -13
  38. package/src/persistence/schema/index.ts +0 -1
  39. package/src/persistence/steps.ts +0 -2
  40. package/src/plugin-api/conversation-turn.ts +7 -31
  41. package/src/plugin-api/index.ts +1 -9
  42. package/src/plugins/defaults/memory/AGENTS.md +2 -14
  43. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +0 -43
  44. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +0 -46
  45. package/src/plugins/defaults/memory/buffer-format.ts +0 -40
  46. package/src/plugins/defaults/memory/context-search/agent-runner.ts +2 -1
  47. package/src/plugins/defaults/memory/context-search/format.ts +1 -2
  48. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +1 -2
  49. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +1 -2
  50. package/src/plugins/defaults/memory/graph/capability-seed.ts +2 -1
  51. package/src/plugins/defaults/memory/graph/tool-handlers.ts +42 -1
  52. package/src/plugins/defaults/memory/host-utils.ts +10 -0
  53. package/src/plugins/defaults/memory/injectors.ts +3 -4
  54. package/src/plugins/defaults/memory/memory-retrospective-job.ts +181 -55
  55. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +99 -407
  56. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +0 -10
  57. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +7 -107
  58. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +86 -307
  59. package/src/plugins/defaults/memory/substrate/page-index.ts +1 -2
  60. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +49 -89
  61. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  62. package/src/plugins/defaults/memory/tools.ts +1 -1
  63. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  64. package/src/plugins/defaults/memory/v1/graph/extraction.ts +1 -2
  65. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  66. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +0 -5
  67. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +2 -5
  68. package/src/plugins/defaults/memory/v2/reranker.ts +1 -2
  69. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +1 -81
  70. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +0 -87
  71. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +0 -21
  72. package/src/plugins/defaults/memory/v3/card.ts +1 -2
  73. package/src/plugins/defaults/memory/v3/injector.ts +178 -212
  74. package/src/plugins/defaults/memory/v3/orchestrate.ts +22 -86
  75. package/src/plugins/defaults/memory/v3/pool-select.ts +7 -10
  76. package/src/plugins/defaults/memory/v3/sections.ts +1 -2
  77. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +1 -10
  78. package/src/plugins/defaults/tool-result-truncate/terminal.ts +46 -1
  79. package/src/runtime/AGENTS.md +1 -1
  80. package/src/runtime/__tests__/agent-wake.test.ts +1 -86
  81. package/src/runtime/agent-wake.ts +4 -20
  82. package/src/runtime/guardian-action-service.ts +17 -2
  83. package/src/runtime/guardian-reply-router.ts +8 -1
  84. package/src/runtime/routes/channel-route-shared.ts +9 -1
  85. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  86. package/src/runtime/routes/desktop-setup-routes.ts +3 -7
  87. package/src/runtime/routes/guardian-approval-interception.ts +0 -24
  88. package/src/runtime/routes/inbound-message-handler.ts +3 -2
  89. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  90. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -7
  91. package/src/schedule/run-script.ts +2 -2
  92. package/src/tools/host-terminal/host-shell.ts +6 -12
  93. package/src/tools/shared/filesystem/file-ops-service.ts +31 -1
  94. package/src/tools/shared/shell-output.test.ts +0 -10
  95. package/src/tools/shared/shell-output.ts +2 -14
  96. package/src/tools/skills/sandbox-runner.ts +2 -13
  97. package/src/tools/skills/scaffold-managed.ts +1 -2
  98. package/src/tools/terminal/__tests__/safe-env.test.ts +0 -29
  99. package/src/tools/terminal/safe-env.ts +1 -30
  100. package/src/tools/terminal/sanitized-bash.ts +2 -15
  101. package/src/tools/terminal/shell.test.ts +0 -29
  102. package/src/tools/terminal/shell.ts +7 -13
  103. package/src/util/host-process.test.ts +1 -17
  104. package/src/util/host-process.ts +0 -24
  105. package/src/util/unicode.ts +0 -29
  106. package/src/__tests__/db-conversation-tool-surface.test.ts +0 -144
  107. package/src/api/events/desktop-activity-changed.ts +0 -10
  108. package/src/persistence/conversation-tool-surface.ts +0 -86
  109. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +0 -78
  110. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +0 -29
  111. package/src/persistence/schema/conversation-tool-surfaces.ts +0 -22
  112. package/src/plugin-api/plugin-channel-turn-trust.test.ts +0 -133
  113. package/src/plugin-api/plugin-channel-turn-trust.ts +0 -71
  114. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +0 -320
  115. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +0 -17
  116. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +0 -161
  117. package/src/plugins/defaults/memory/buffer-file.ts +0 -354
  118. package/src/plugins/defaults/memory/memory-run-evidence.ts +0 -213
  119. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +0 -34
  120. package/src/tools/terminal/shell-launch.test.ts +0 -162
@@ -42,11 +42,9 @@
42
42
  * - infrastructure failure (selector provider unavailable — e.g. a transient
43
43
  * CES credential blip drops the API key — or no usable `tool_use` / schema
44
44
  * mismatch surviving the short re-prompt retry) → throw
45
- * {@link MemoryV3RetrievalUnavailableError}. The orchestrator keeps the
46
- * stable prefix unjudged for the turn and drops the finder candidates,
47
- * recording the pool with `selector_ran = 0`; the live injector renders
48
- * that prefix and queues a notice that the turn drew on core memories
49
- * only.
45
+ * {@link MemoryV3RetrievalUnavailableError}. The live injector treats this
46
+ * as a logged memory miss for the turn; shadow/observation callers swallow
47
+ * it so v2 retrieval can serve the turn.
50
48
  */
51
49
 
52
50
  import type {
@@ -54,12 +52,12 @@ import type {
54
52
  Message,
55
53
  ToolUseContent,
56
54
  } from "@vellumai/plugin-api";
57
- import { getConfiguredProvider, safeStringSlice } from "@vellumai/plugin-api";
55
+ import { getConfiguredProvider } from "@vellumai/plugin-api";
58
56
  import { z } from "zod";
59
57
 
60
58
  import { classifyConversationError } from "../../../../daemon/conversation-error.js";
61
59
  import type { PendingConversationNotice } from "../../../../daemon/conversation-notices.js";
62
- import { redactLogString, truncate } from "../host-utils.js";
60
+ import { redactLogString, safeStringSlice, truncate } from "../host-utils.js";
63
61
  import {
64
62
  cachedTextBlock,
65
63
  extractToolUse,
@@ -502,9 +500,8 @@ export interface PoolSelection {
502
500
  *
503
501
  * An omitted `ids` keeps ALL candidates (the recall-safe "all of these are
504
502
  * relevant" signal, `keptAll: true`); an explicit `[]` keeps none; an
505
- * infrastructure failure (after a short re-prompt retry) throws
506
- * {@link MemoryV3RetrievalUnavailableError}, and the orchestrator keeps the
507
- * stable prefix unjudged in its place.
503
+ * infrastructure failure (after a short re-prompt retry) keeps none, degrading
504
+ * to the deterministic recall lanes the orchestrator unions in.
508
505
  *
509
506
  * `systemPrompt` is the selector's instruction scaffold; it defaults to the
510
507
  * bundled {@link SYSTEM_PROMPT} and is overridable via `memory.v3.selectorPromptPath`
@@ -1,5 +1,4 @@
1
- import { safeStringSlice } from "@vellumai/plugin-api";
2
-
1
+ import { safeStringSlice } from "../host-utils.js";
3
2
  import type { Section, SectionIndex, Slug } from "./types.js";
4
3
 
5
4
  /**
@@ -32,7 +32,6 @@ import {
32
32
  getMessages,
33
33
  listInstalledSkills,
34
34
  parseMessageMetadata,
35
- safeStringSlice,
36
35
  stringifyMessageContent,
37
36
  VOICE_ESCALATION_CONTINUATION_MESSAGE_KIND,
38
37
  } from "@vellumai/plugin-api";
@@ -45,7 +44,7 @@ import {
45
44
  timeLatencySubSpan,
46
45
  } from "../../../../daemon/turn-latency-sub-spans.js";
47
46
  import { enqueueMemoryJob } from "../../../../persistence/jobs-store.js";
48
- import { stripCommentLines } from "../host-utils.js";
47
+ import { safeStringSlice, stripCommentLines } from "../host-utils.js";
49
48
  import { getLogger } from "../logging.js";
50
49
  import { type MemorySqlite, memorySqliteOrNull } from "../memory-db.js";
51
50
  import { getWorkspaceDir, getWorkspacePromptPath } from "../paths.js";
@@ -636,16 +635,8 @@ interface SelectionRow {
636
635
  * The row holds one section per slug: the selection's FIRST selected section
637
636
  * (pool order) stands for the page; a page selected with no section (a card,
638
637
  * 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.
644
638
  */
645
639
  export function attributeSelections(result: OrchestrateResult): SelectionRow[] {
646
- if (result.selectorFailure) {
647
- return [];
648
- }
649
640
  const core = new Set<Slug>(result.lanes.core);
650
641
  const hot = new Set<Slug>(result.lanes.hot);
651
642
  const fresh = new Set<Slug>(result.lanes.fresh);
@@ -6,7 +6,52 @@
6
6
  * This module is side-effect free: importing it does not register any plugin.
7
7
  */
8
8
 
9
- import { safeStringSlice } from "@vellumai/plugin-api";
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
+ }
10
55
 
11
56
  /**
12
57
  * 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
- 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.
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`.
@@ -18,10 +18,9 @@
18
18
  * route captured calls back to the originating conversation's probe arrays.
19
19
  */
20
20
 
21
- import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
21
+ import { beforeEach, describe, expect, mock, 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";
25
24
 
26
25
  // ── Per-conversation capture registry ────────────────────────────────
27
26
  //
@@ -502,13 +501,6 @@ function makeWakeConversation(options: {
502
501
  `tools:${snapshotAllowedTools()?.join(",") ?? "all"}`,
503
502
  );
504
503
  },
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
- },
512
504
  get wakePersonaOverride() {
513
505
  return wakePersonaOverride;
514
506
  },
@@ -1365,42 +1357,6 @@ describe("wakeAgentForOpportunity", () => {
1365
1357
  expect(conversation.toolContextPin).toBeUndefined();
1366
1358
  });
1367
1359
 
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
-
1404
1360
  test("defaults to the wire gate mode when toolGateMode is absent", async () => {
1405
1361
  let gateModeDuringRun: string | undefined;
1406
1362
  const conversation = makeWakeConversation({
@@ -1561,47 +1517,6 @@ describe("wakeAgentForOpportunity", () => {
1561
1517
  expect(conversation.pushedMessages[2]).toEqual(followupAssistant);
1562
1518
  });
1563
1519
 
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
-
1605
1520
  test("marks processing true during the run and false afterwards", async () => {
1606
1521
  const conversation = makeWakeConversation({
1607
1522
  baseline: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
@@ -107,7 +107,6 @@ import {
107
107
  persistWakeTriggerMessage,
108
108
  scopeWakeAllowedTools,
109
109
  } from "../daemon/wake-conversation-ops.js";
110
- import { desktopAutomationLease } from "../desktop/desktop-automation-lease.js";
111
110
  import {
112
111
  recordCompactionEndBestEffort,
113
112
  recordCompactionStartBestEffort,
@@ -119,7 +118,7 @@ import {
119
118
  setAgentLoopExitReasonOnLatestLog,
120
119
  } from "../persistence/llm-request-log-store.js";
121
120
  import type { SystemPromptPersonaOverride } from "../prompts/system-prompt.js";
122
- import type { Message, ToolDefinition } from "../providers/types.js";
121
+ import type { Message } from "../providers/types.js";
123
122
  import {
124
123
  type UntrustedContentSource,
125
124
  wrapUntrustedContent,
@@ -351,17 +350,6 @@ export interface WakeOptions {
351
350
  * skill-management authoring tools callable directly.
352
351
  */
353
352
  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[];
365
353
  /**
366
354
  * Explicit persona/channel slugs for the wake's system-prompt build,
367
355
  * applied to the conversation for the duration of the run and restored
@@ -1422,7 +1410,6 @@ export async function wakeAgentForOpportunity(
1422
1410
  * is one function rather than a rebuild bolted onto either half.
1423
1411
  */
1424
1412
  const restoreWakeTurnScope = (): void => {
1425
- desktopAutomationLease.releaseForConversation(conversationId);
1426
1413
  restoreWakeAllowedTools();
1427
1414
  clearWakePersonaOverride();
1428
1415
  syncWakeLoopSystemPrompt();
@@ -1435,12 +1422,9 @@ export async function wakeAgentForOpportunity(
1435
1422
  restoreWakeToolScope = scopeWakeAllowedTools(
1436
1423
  conversation,
1437
1424
  new Set(opts.allowedTools),
1438
- {
1439
- gateMode: opts.toolGateMode,
1440
- toolContextPin: opts.toolContextPin,
1441
- preactivateSkillIds: opts.preactivateSkillIds,
1442
- wireToolDefinitions: opts.wireToolDefinitions,
1443
- },
1425
+ opts.toolGateMode,
1426
+ opts.toolContextPin,
1427
+ opts.preactivateSkillIds,
1444
1428
  );
1445
1429
  return true;
1446
1430
  } catch (err) {
@@ -16,6 +16,21 @@ 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
+
19
34
  // ---------------------------------------------------------------------------
20
35
  // Types
21
36
  // ---------------------------------------------------------------------------
@@ -83,8 +98,8 @@ export async function processGuardianDecision(
83
98
  ): Promise<ProcessGuardianDecisionResult> {
84
99
  const { requestId, conversationId, channel, actorContext } = params;
85
100
 
86
- // 1. Validate the action
87
- const action = params.action;
101
+ // 1. Canonicalize legacy actions, then validate
102
+ const action = LEGACY_ACTION_MAP[params.action] ?? params.action;
88
103
  if (!isApprovalAction(action)) {
89
104
  return {
90
105
  ok: false,
@@ -153,6 +153,12 @@ 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
+
156
162
  interface ParsedCallback {
157
163
  requestId: string;
158
164
  action: ApprovalAction;
@@ -164,7 +170,8 @@ function parseCallbackAction(data: string): ParsedCallback | null {
164
170
  return null;
165
171
  }
166
172
  const requestId = parts[1];
167
- const action = parts.slice(2).join(":");
173
+ const rawAction = parts.slice(2).join(":");
174
+ const action = LEGACY_CALLBACK_MAP[rawAction] ?? rawAction;
168
175
  if (!requestId || !isApprovalAction(action)) {
169
176
  return null;
170
177
  }
@@ -29,6 +29,13 @@ 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
+
32
39
  export function parseCallbackData(
33
40
  data: string,
34
41
  sourceChannel?: string,
@@ -38,7 +45,8 @@ export function parseCallbackData(
38
45
  return null;
39
46
  }
40
47
  const requestId = parts[1];
41
- const action = parts.slice(2).join(":");
48
+ const rawAction = parts.slice(2).join(":");
49
+ const action = LEGACY_CALLBACK_MAP[rawAction] ?? rawAction;
42
50
  if (!requestId || !isApprovalAction(action)) {
43
51
  return null;
44
52
  }
@@ -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", automationActive: false }
83
- : { state: "installing", stage: "packages", automationActive: false },
82
+ ? { state: "required" }
83
+ : { state: "installing", stage: "packages" },
84
84
  );
85
85
  expect(route.method === "GET" ? status : start).toHaveBeenCalledTimes(1);
86
86
  expect(route.method === "GET" ? start : status).not.toHaveBeenCalled();
@@ -1,7 +1,6 @@
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";
5
4
  import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
6
5
  import { isVirtualDesktopEnabled } from "../../desktop/virtual-desktop-feature.js";
7
6
  import { GATEWAY_PRINCIPALS } from "../auth/route-policy.js";
@@ -10,7 +9,6 @@ import type { RouteDefinition } from "./types.js";
10
9
 
11
10
  const statusSchema = z.object({
12
11
  state: z.enum(["required", "installing", "ready", "failed", "unsupported"]),
13
- automationActive: z.boolean().optional(),
14
12
  stage: z.enum(["packages", "chrome", "checking"]).optional(),
15
13
  });
16
14
 
@@ -26,11 +24,9 @@ export const ROUTES: RouteDefinition[] = ["GET", "POST"].map((method) => ({
26
24
  "Virtual desktop is available only on enabled platform-hosted assistants",
27
25
  );
28
26
  }
29
- const status =
30
- method === "GET"
31
- ? desktopDependencyInstaller.getStatus()
32
- : desktopDependencyInstaller.start();
33
- return { ...status, automationActive: desktopAutomationLease.isActive };
27
+ return method === "GET"
28
+ ? desktopDependencyInstaller.getStatus()
29
+ : desktopDependencyInstaller.start();
34
30
  },
35
31
  summary:
36
32
  method === "GET"
@@ -299,30 +299,6 @@ 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
- }
326
302
  }
327
303
 
328
304
  // ── Conversational approval engine for plain-text messages ──
@@ -1531,7 +1531,8 @@ export async function handleChannelInbound({
1531
1531
  sourceMetadata.account.length > 0
1532
1532
  ? sourceMetadata.account
1533
1533
  : undefined;
1534
- const botMentioned = sourceMetadata?.botMentioned === true;
1534
+ const slackBotMentioned =
1535
+ sourceChannel === "slack" && sourceMetadata?.slackBotMentioned === true;
1535
1536
 
1536
1537
  // ── DM cold-start backfill ──
1537
1538
  // First time a Slack DM without thread_ts lands in a conversation that
@@ -1624,7 +1625,7 @@ export async function handleChannelInbound({
1624
1625
  approvalCopyGenerator,
1625
1626
  chatType: sourceChatType,
1626
1627
  clientTimezone: inboundClientTimezone,
1627
- botMentioned,
1628
+ slackBotMentioned,
1628
1629
  slackInbound,
1629
1630
  channelInbound,
1630
1631
  });
@@ -1169,7 +1169,7 @@ describe("channel activity timing", () => {
1169
1169
  externalChatId: channelId,
1170
1170
  trustCtx,
1171
1171
  metadataHints: [],
1172
- botMentioned: true,
1172
+ slackBotMentioned: true,
1173
1173
  replyCallbackUrl: `https://example.test/deliver/slack?channel=${channelId}&threadTs=${threadTs}`,
1174
1174
  });
1175
1175
 
@@ -105,11 +105,8 @@ 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
- /**
109
- * The message addresses the assistant by name, as stated by the channel
110
- * (`sourceMetadata.botMentioned`). Absent means not established.
111
- */
112
- botMentioned?: boolean;
108
+ /** Slack app_mention/direct bot mention signal from the gateway. */
109
+ slackBotMentioned?: boolean;
113
110
  /**
114
111
  * Slack-specific inbound metadata extracted at the HTTP boundary. Threaded
115
112
  * through to `persistUserMessage` so the row can be tagged with a
@@ -169,7 +166,7 @@ export function processChannelMessageInBackground(
169
166
  sourceLanguageCode,
170
167
  chatType,
171
168
  clientTimezone,
172
- botMentioned,
169
+ slackBotMentioned,
173
170
  slackInbound,
174
171
  channelInbound,
175
172
  slackReactionRowMeta,
@@ -207,7 +204,7 @@ export function processChannelMessageInBackground(
207
204
  initiatorUserId: slackInbound?.actorExternalUserId,
208
205
  startImmediately: shouldShowActivityImmediately({
209
206
  chatType,
210
- botMentioned,
207
+ botMentioned: slackBotMentioned,
211
208
  }),
212
209
  });
213
210
  const stopApprovalWatcher = replyCallbackUrl
@@ -1,7 +1,6 @@
1
1
  import { buildSanitizedEnv } from "../tools/terminal/safe-env.js";
2
2
  import {
3
3
  buildShellInvocation,
4
- buildShellSpawnFlags,
5
4
  terminateProcessTree,
6
5
  } from "../util/host-process.js";
7
6
  import { getLogger } from "../util/logger.js";
@@ -65,9 +64,10 @@ export async function runScript(
65
64
  const shell = buildShellInvocation(command);
66
65
  const proc = Bun.spawn([shell.command, ...shell.args], {
67
66
  cwd,
68
- ...buildShellSpawnFlags(),
67
+ detached: true,
69
68
  stdout: "pipe",
70
69
  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,10 +23,8 @@ import { conversationRevealNonce } from "../../runtime/reveal-nonce.js";
23
23
  import { redactSecrets } from "../../security/secret-scanner.js";
24
24
  import {
25
25
  buildShellInvocation,
26
- buildShellSpawnFlags,
27
26
  prependUniquePathEntries,
28
27
  terminateProcessTree,
29
- watchShellProcessStart,
30
28
  } from "../../util/host-process.js";
31
29
  import { getLogger } from "../../util/logger.js";
32
30
  import type { CompletedBackgroundTool } from "../background-tool-registry.js";
@@ -457,9 +455,9 @@ export const hostShellTool = {
457
455
  cwd: workingDir,
458
456
  env: hostEnv,
459
457
  stdio: ["ignore", "pipe", "pipe"],
460
- ...buildShellSpawnFlags(),
458
+ detached: true,
459
+ windowsHide: true,
461
460
  });
462
- const launch = watchShellProcessStart(child);
463
461
 
464
462
  const collector = attachBoundedStdio(child);
465
463
  let timedOut = false;
@@ -485,9 +483,7 @@ export const hostShellTool = {
485
483
  }
486
484
  completed = true;
487
485
  clearTimeout(timer);
488
- const result = collector.format(code, timedOut, timeoutSec, {
489
- started: launch.didStart(),
490
- });
486
+ const result = collector.format(code, timedOut, timeoutSec);
491
487
  // Cancel takes precedence over the SIGKILL-induced error result.
492
488
  const status = aborted
493
489
  ? "cancelled"
@@ -633,9 +629,9 @@ export const hostShellTool = {
633
629
  cwd: workingDir,
634
630
  env: hostEnv,
635
631
  stdio: ["ignore", "pipe", "pipe"],
636
- ...buildShellSpawnFlags(),
632
+ detached: true,
633
+ windowsHide: true,
637
634
  });
638
- const launch = watchShellProcessStart(child);
639
635
  const collector = attachBoundedStdio(child, {
640
636
  onOutput: context.onOutput,
641
637
  });
@@ -661,9 +657,7 @@ export const hostShellTool = {
661
657
  clearTimeout(timer);
662
658
  context.signal?.removeEventListener("abort", onAbort);
663
659
 
664
- const result = collector.format(code, timedOut, timeoutSec, {
665
- started: launch.didStart(),
666
- });
660
+ const result = collector.format(code, timedOut, timeoutSec);
667
661
 
668
662
  resolve({
669
663
  content: result.content,
@@ -4,7 +4,6 @@ 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";
8
7
  import { isAbortLikeError } from "../abort.js";
9
8
  import { applyEdit } from "./edit-engine.js";
10
9
  import * as Err from "./errors.js";
@@ -103,6 +102,37 @@ function truncationNotice(
103
102
  return `\n\n[Truncated: characters ${start}-${end} of ${totalChars}. Read on with start_index=${end}.]`;
104
103
  }
105
104
 
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
+
106
136
  export class FileSystemOps {
107
137
  private policy: PathPolicy;
108
138
  private sizeLimit: number | undefined;