@cuongtran001/kanna 1.76.2 → 1.76.4

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 (96) hide show
  1. package/dist/client/assets/{BoardsPage-BvYUo0PD.js → BoardsPage-e9KN-PqL.js} +1 -1
  2. package/dist/client/assets/{BoardsRoutePage-D5hLNJ2q.js → BoardsRoutePage-BfAUEg94.js} +1 -1
  3. package/dist/client/assets/ChatPage-CoLgIycl.js +93 -0
  4. package/dist/client/assets/{CronJobRow-VCv0jyI2.js → CronJobRow-R6sGCVRD.js} +1 -1
  5. package/dist/client/assets/{CronJobsPage-8FE4cO2q.js → CronJobsPage-DK-B2TNe.js} +1 -1
  6. package/dist/client/assets/{FilePreviewSheet-B5b8L-4e.js → FilePreviewSheet-O3nyFK2i.js} +2 -2
  7. package/dist/client/assets/{FlowSurface-DmNZQO7Z.js → FlowSurface-De4fzKB9.js} +1 -1
  8. package/dist/client/assets/{GenUIBlock-DX1nLbtf.js → GenUIBlock-D6oiFJaL.js} +2 -2
  9. package/dist/client/assets/{KannaSocketProvider-CiuKyRYE.js → KannaSocketProvider-BHItG6ob.js} +1 -1
  10. package/dist/client/assets/{LocalProjectsPage-DjCZmQ8c.js → LocalProjectsPage-C4YvkutW.js} +1 -1
  11. package/dist/client/assets/SettingsPage-DuDvIWI7.js +13 -0
  12. package/dist/client/assets/{StackBoardsRoutePage-i7WMqsk0.js → StackBoardsRoutePage-CEaF8HQA.js} +1 -1
  13. package/dist/client/assets/{ToolCallMessage-BhxowUjs.js → ToolCallMessage-CCR_uV98.js} +1 -1
  14. package/dist/client/assets/{VChartSurface-DQ6HIQXe.js → VChartSurface-JKYw0D-r.js} +1 -1
  15. package/dist/client/assets/{WorkflowsPage-BCIsFa6f.js → WorkflowsPage-B1ZvlJZE.js} +1 -1
  16. package/dist/client/assets/{WorkflowsSection-B5TkVqRA.js → WorkflowsSection-CCRo-MxO.js} +1 -1
  17. package/dist/client/assets/{chart-renderer-DQLdORyQ.js → chart-renderer-BM8heEao.js} +1 -1
  18. package/dist/client/assets/{chatSounds-CUN4vZG0.js → chatSounds-BmmQ91hl.js} +1 -1
  19. package/dist/client/assets/chevron-left-yjfH1ZSB.js +1 -0
  20. package/dist/client/assets/{clipboard.adapter-BvW6oQa_.js → clipboard.adapter-CJ_H99S1.js} +1 -1
  21. package/dist/client/assets/{columnStyle-C0IvZxLY.js → columnStyle-Dn_3F527.js} +1 -1
  22. package/dist/client/assets/{contextWindow-WJUHfP_d.js → contextWindow-BA0xOQMO.js} +1 -1
  23. package/dist/client/assets/copyFeedbackStore-CUQsds64.js +1 -0
  24. package/dist/client/assets/{createScopedStore-DW4wfjbM.js → createScopedStore-DqP7mPc0.js} +1 -1
  25. package/dist/client/assets/{dist-Q8elClTa.js → dist-ChIeJWx6.js} +1 -1
  26. package/dist/client/assets/{ellipsis-D_1qLZbX.js → ellipsis-hKiqLkY4.js} +1 -1
  27. package/dist/client/assets/{flow-kinds-Df0GmP61.js → flow-kinds-C9nOfZHH.js} +1 -1
  28. package/dist/client/assets/{folder-UHukK2MD.js → folder-BiZuYd4N.js} +1 -1
  29. package/dist/client/assets/{formatDuration-BA1K5CFs.js → formatDuration-C6zrNbeh.js} +1 -1
  30. package/dist/client/assets/{genui-CZI72r1O.js → genui-CnB2lRaB.js} +1 -1
  31. package/dist/client/assets/{globe-CQI7GjsK.js → globe-D4_HmNVj.js} +1 -1
  32. package/dist/client/assets/{host-C0p_56Re.js → host-B3gUJGiJ.js} +1 -1
  33. package/dist/client/assets/index-BYus2o1t.css +1 -0
  34. package/dist/client/assets/{index-3lHvRekg.js → index-uQsXmgHP.js} +3 -3
  35. package/dist/client/assets/{info-BXzVPePc.js → info-B_Vqy-5o.js} +1 -1
  36. package/dist/client/assets/{input-BerPPEIs.js → input-DRve70PS.js} +1 -1
  37. package/dist/client/assets/{lexicalToReact-DdXqoi0d.js → lexicalToReact-BFuz5nt-.js} +3 -3
  38. package/dist/client/assets/{log-7fGlFSON.js → log-DWUXpF9D.js} +1 -1
  39. package/dist/client/assets/{monitor-BOFtyQiW.js → monitor-DDe-a8Wg.js} +1 -1
  40. package/dist/client/assets/{pendingActionsStore-idc6m2Ya.js → pendingActionsStore-in-8OIop.js} +1 -1
  41. package/dist/client/assets/{play-DltDjX9H.js → play-CCpwuwmW.js} +1 -1
  42. package/dist/client/assets/{plus-CwWMJ0a-.js → plus-DF1Xx1dC.js} +1 -1
  43. package/dist/client/assets/{popover-gn1uvc-1.js → popover-CuSv5Wru.js} +1 -1
  44. package/dist/client/assets/{pushClient-NeXrldi-.js → pushClient-DtF2D2cW.js} +1 -1
  45. package/dist/client/assets/runDetached-B5RMTcFn.js +1 -0
  46. package/dist/client/assets/{segmented-control-3UDSwswP.js → segmented-control-2bnq74MU.js} +1 -1
  47. package/dist/client/assets/{select-Df67vheJ.js → select-BZMQ2erm.js} +1 -1
  48. package/dist/client/assets/{settings-header-button-CKZHCdXh.js → settings-header-button-Df7-MZFW.js} +1 -1
  49. package/dist/client/assets/{sidebarPendingActions-B8iNQJfE.js → sidebarPendingActions-DPIIvMHh.js} +1 -1
  50. package/dist/client/assets/{square-pen-DJjPW4f4.js → square-pen-CKf_gOOo.js} +1 -1
  51. package/dist/client/assets/{state-mark-CU2hw5D9.js → state-mark-DVh0A9N6.js} +1 -1
  52. package/dist/client/assets/{terminal-8Bxl5uZh.js → terminal-CHsxiDwH.js} +1 -1
  53. package/dist/client/assets/{timer.adapter-BD7zwXEm.js → timer.adapter-Besjjb6B.js} +1 -1
  54. package/dist/client/assets/{tools-Lo2VrFmM.js → tools-B1sdThgL.js} +1 -1
  55. package/dist/client/assets/{triangle-alert-CQwDyl4K.js → triangle-alert-DlaH1f-p.js} +1 -1
  56. package/dist/client/assets/{useAppGlobalState-uP5C1BG8.js → useAppGlobalState-cMGGvgsS.js} +1 -1
  57. package/dist/client/assets/{useKannaState-B2eDC-Dw.js → useKannaState-gc-Chi9u.js} +1 -1
  58. package/dist/client/assets/{useTheme-B_JDh31l.js → useTheme-B1rO5zPx.js} +1 -1
  59. package/dist/client/index.html +34 -34
  60. package/package.json +1 -1
  61. package/src/server/agent-coordinator-types.ts +1 -0
  62. package/src/server/agent-coordinator.ts +4 -0
  63. package/src/server/beacon-mention-prompt.test.ts +33 -0
  64. package/src/server/beacon-mention-prompt.ts +33 -0
  65. package/src/server/beacon-registry.test.ts +1 -0
  66. package/src/server/beacon-registry.ts +9 -1
  67. package/src/server/beacon-services.test.ts +11 -1
  68. package/src/server/beacon-services.ts +4 -0
  69. package/src/server/beacon-settings.test.ts +74 -1
  70. package/src/server/beacon-settings.ts +54 -3
  71. package/src/server/claude-turn-starter-types.ts +3 -0
  72. package/src/server/claude-turn-starter.test.ts +85 -0
  73. package/src/server/claude-turn-starter.ts +5 -1
  74. package/src/server/kanna-mcp-beacon-transfer.test.ts +1 -0
  75. package/src/server/kanna-mcp-beacon.test.ts +4 -1
  76. package/src/server/kanna-mcp-beacon.ts +16 -5
  77. package/src/server/kanna-mcp-tools/chat-tasks.ts +1 -1
  78. package/src/server/push/push-manager.test.ts +39 -0
  79. package/src/server/push/push-manager.ts +45 -1
  80. package/src/server/server.ts +1 -0
  81. package/src/server/ws-router-broadcast.ts +1 -1
  82. package/src/server/ws-router-envelope.ts +2 -0
  83. package/src/server/ws-router-settings.test.ts +1 -0
  84. package/src/server/ws-router.test.ts +68 -0
  85. package/src/shared/beacon-alias.test.ts +64 -0
  86. package/src/shared/beacon-alias.ts +120 -0
  87. package/src/shared/beacon-config.ts +2 -0
  88. package/src/shared/beacon-status.test.ts +2 -0
  89. package/src/shared/beacon-status.ts +2 -0
  90. package/src/shared/chat-activity.ts +8 -0
  91. package/dist/client/assets/ChatPage-zfy8nznW.js +0 -93
  92. package/dist/client/assets/SettingsPage-ELjbPQEw.js +0 -13
  93. package/dist/client/assets/chevron-left-DpI8tMxJ.js +0 -1
  94. package/dist/client/assets/copyFeedbackStore-BsjXKW0-.js +0 -1
  95. package/dist/client/assets/index-D8o_YX_z.css +0 -1
  96. package/dist/client/assets/runDetached-BWsF_7Xo.js +0 -1
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto"
2
+ import { validateBeaconAliasFormat } from "../shared/beacon-alias"
2
3
  import type { BeaconConfig, BeaconInput, BeaconPatch } from "../shared/beacon-config"
3
4
  import type { BeaconOs } from "../shared/beacon-protocol"
4
5
  import { DEFAULT_BEACON_SCOPE, type BeaconScope } from "../shared/beacon-scope"
@@ -27,6 +28,17 @@ export function validateBeaconLabel(
27
28
  return taken ? `Beacon label "${trimmed}" is already in use` : null
28
29
  }
29
30
 
31
+ export function validateBeaconAlias(
32
+ alias: string,
33
+ others: readonly { id: string; alias?: string }[],
34
+ selfId?: string,
35
+ ): string | null {
36
+ const formatError = validateBeaconAliasFormat(alias)
37
+ if (formatError) return formatError
38
+ const taken = others.some((other) => other.id !== selfId && other.alias === alias)
39
+ return taken ? `Short name "${alias}" is already in use` : null
40
+ }
41
+
30
42
  function readString(object: JsonObject, key: string): string | null {
31
43
  const value = object[key]
32
44
  return typeof value === "string" ? value : null
@@ -78,7 +90,27 @@ export function normalizeScope(raw: JsonValue | undefined): BeaconScope {
78
90
  }
79
91
  }
80
92
 
81
- function normalizeBeaconEntry(entry: JsonValue, warnings: string[]): BeaconConfig | null {
93
+ function readAlias(
94
+ entry: JsonObject,
95
+ id: string,
96
+ warnings: string[],
97
+ takenAliases: ReadonlySet<string>,
98
+ ): string | null {
99
+ const alias = readString(entry, "alias")
100
+ if (alias === null) return null
101
+ const error = validateBeaconAliasFormat(alias) ?? (takenAliases.has(alias) ? "alias is already in use" : null)
102
+ if (error) {
103
+ warnings.push(`Beacon '${id}' alias dropped: ${error}`)
104
+ return null
105
+ }
106
+ return alias
107
+ }
108
+
109
+ function normalizeBeaconEntry(
110
+ entry: JsonValue,
111
+ warnings: string[],
112
+ takenAliases: ReadonlySet<string>,
113
+ ): BeaconConfig | null {
82
114
  if (!isJsonObject(entry)) {
83
115
  warnings.push("customBeacons entry must be an object")
84
116
  return null
@@ -92,9 +124,11 @@ function normalizeBeaconEntry(entry: JsonValue, warnings: string[]): BeaconConfi
92
124
  return null
93
125
  }
94
126
  const createdAt = readString(entry, "createdAt") ?? new Date(0).toISOString()
127
+ const alias = readAlias(entry, id, warnings, takenAliases)
95
128
  return {
96
129
  id,
97
130
  label,
131
+ ...(alias === null ? {} : { alias }),
98
132
  publicKey,
99
133
  os,
100
134
  scope: normalizeScope(entry.scope),
@@ -113,8 +147,9 @@ export function normalizeBeacons(raw: JsonValue | undefined, warnings: string[])
113
147
  const out: BeaconConfig[] = []
114
148
  const seenIds = new Set<string>()
115
149
  const seenLabels = new Set<string>()
150
+ const seenAliases = new Set<string>()
116
151
  for (const entry of raw) {
117
- const normalized = normalizeBeaconEntry(entry, warnings)
152
+ const normalized = normalizeBeaconEntry(entry, warnings, seenAliases)
118
153
  if (!normalized) continue
119
154
  if (seenIds.has(normalized.id) || seenLabels.has(normalized.label)) {
120
155
  warnings.push(`Beacon '${normalized.id}' rejected: duplicate id or label`)
@@ -122,6 +157,7 @@ export function normalizeBeacons(raw: JsonValue | undefined, warnings: string[])
122
157
  }
123
158
  seenIds.add(normalized.id)
124
159
  seenLabels.add(normalized.label)
160
+ if (normalized.alias !== undefined) seenAliases.add(normalized.alias)
125
161
  out.push(normalized)
126
162
  }
127
163
  return out
@@ -133,6 +169,18 @@ function checkedLabel(label: string, current: readonly BeaconConfig[], selfId?:
133
169
  return label.trim()
134
170
  }
135
171
 
172
+ function checkedAlias(
173
+ alias: string | null | undefined,
174
+ existing: BeaconConfig,
175
+ current: readonly BeaconConfig[],
176
+ ): string | undefined {
177
+ if (alias === undefined) return existing.alias
178
+ if (alias === null) return undefined
179
+ const error = validateBeaconAlias(alias, current, existing.id)
180
+ if (error) throw new BeaconValidationException({ code: "INVALID_ALIAS", message: error })
181
+ return alias
182
+ }
183
+
136
184
  export function createBeacon(input: BeaconInput, current: readonly BeaconConfig[]): BeaconConfig {
137
185
  const now = new Date().toISOString()
138
186
  return {
@@ -153,8 +201,11 @@ export function updateBeacon(
153
201
  current: readonly BeaconConfig[],
154
202
  ): BeaconConfig {
155
203
  const label = patch.label === undefined ? existing.label : checkedLabel(patch.label, current, existing.id)
204
+ const alias = checkedAlias(patch.alias, existing, current)
205
+ const { alias: _previousAlias, ...withoutAlias } = existing
156
206
  return {
157
- ...existing,
207
+ ...withoutAlias,
208
+ ...(alias === undefined ? {} : { alias }),
158
209
  label,
159
210
  scope: patch.scope ?? existing.scope,
160
211
  enabled: patch.enabled ?? existing.enabled,
@@ -6,6 +6,7 @@ import type {
6
6
  Subagent,
7
7
  TranscriptEntry,
8
8
  } from "../shared/types"
9
+ import type { BeaconConfig } from "../shared/beacon-config"
9
10
  import type { CronRunTag } from "../shared/cron/types"
10
11
  import type { JsonValue } from "../shared/json"
11
12
  import type { KannaSystemPromptOptions, SkillRosterEntry } from "../shared/kanna-system-prompt"
@@ -52,6 +53,8 @@ export interface StartTurnDeps {
52
53
  clearDrainingStream: (chatId: string) => void
53
54
  emitStateChange: (chatId: string, options?: { immediate?: boolean }) => void
54
55
  getSubagents: () => Subagent[]
56
+ getBeacons: () => readonly BeaconConfig[]
57
+ isBeaconOnline: (beaconId: string) => boolean
55
58
  getAppSettingsSnapshot: () => StartTurnAppSettings
56
59
  listSkills: (chatId: string) => SkillRosterEntry[]
57
60
  generateTitleInBackground: (chatId: string, content: string, localPath: string, optimisticTitle: string) => Promise<void>
@@ -6,6 +6,8 @@ import { PendingToolSlots } from "./pending-tool-slot"
6
6
  import type { HarnessTurn } from "./harness-types"
7
7
  import type { TranscriptEntry } from "../shared/types"
8
8
  import { renderGenUIPromptSection } from "../shared/genui"
9
+ import type { BeaconConfig } from "../shared/beacon-config"
10
+ import { DEFAULT_BEACON_SCOPE } from "../shared/beacon-scope"
9
11
 
10
12
 
11
13
  function makeFakeTurn(): HarnessTurn {
@@ -82,6 +84,8 @@ function makeDeps(overrides: Partial<StartTurnDeps> = {}): StartTurnDeps {
82
84
  clearDrainingStream: mock(() => {}),
83
85
  emitStateChange: mock(() => {}),
84
86
  getSubagents: mock(() => []),
87
+ getBeacons: () => [],
88
+ isBeaconOnline: () => false,
85
89
  getAppSettingsSnapshot: mock(() => ({ globalPromptAppend: undefined })),
86
90
  listSkills: mock(() => []),
87
91
  generateTitleInBackground: mock(async () => {}),
@@ -641,3 +645,84 @@ describe("@project mentions", () => {
641
645
  expect(startClaudeTurn.mock.calls[0]![0].additionalDirectories).toEqual([])
642
646
  })
643
647
  })
648
+
649
+ describe("@beacon mentions", () => {
650
+ const beacon: BeaconConfig = {
651
+ id: "beacon-1",
652
+ label: "MacBook-Pro.local",
653
+ alias: "office-mac",
654
+ publicKey: "pk",
655
+ os: "darwin",
656
+ scope: DEFAULT_BEACON_SCOPE,
657
+ enabled: true,
658
+ createdAt: "2026-10-13T00:00:00.000Z",
659
+ updatedAt: "2026-10-13T00:00:00.000Z",
660
+ }
661
+
662
+ function makeBeaconDeps() {
663
+ const chat = makeFakeChatRecord({ provider: "claude", title: "Existing" })
664
+ const project = makeFakeProjectRecord()
665
+ const appended: Array<{ kind?: string; content?: string }> = []
666
+ const sentPrompts: string[] = []
667
+ const claudeSessions = new Map<string, ClaudeSessionState>()
668
+ const deps = makeDeps({
669
+ claudeSessions,
670
+ getBeacons: () => [beacon],
671
+ isBeaconOnline: () => true,
672
+ store: {
673
+ requireChat: () => chat,
674
+ getMessages: () => [],
675
+ getRecentRawEntries: () => [] as readonly TranscriptEntry[],
676
+ getProject: () => project,
677
+ appendMessage: async (_chatId: string, entry: { kind?: string; content?: string }) => {
678
+ appended.push(entry)
679
+ },
680
+ setChatProvider: async () => {},
681
+ setPlanMode: async () => {},
682
+ renameChat: async () => {},
683
+ recordTurnStarted: async () => {},
684
+ recordTurnFailed: async () => {},
685
+ setPendingForkSessionToken: async () => {},
686
+ } as unknown as StartTurnDeps["store"],
687
+ startClaudeTurn: async () => {
688
+ claudeSessions.set("chat-1", {
689
+ id: "session-1",
690
+ nextPromptSeq: 0,
691
+ pendingPromptSeqs: [],
692
+ cancelledResultPending: 0,
693
+ lastUsedAt: Date.now(),
694
+ session: { sendPrompt: async (prompt: string) => { sentPrompts.push(prompt) } },
695
+ } as unknown as ClaudeSessionState)
696
+ return makeFakeTurn()
697
+ },
698
+ })
699
+ return { deps, appended, sentPrompts }
700
+ }
701
+
702
+ test("a resolved mention gives the provider the beacon id while the stored prompt stays as typed", async () => {
703
+ const { deps, appended, sentPrompts } = makeBeaconDeps()
704
+ const typed = "run uname on @beacon/office-mac"
705
+
706
+ await startTurnForChat(deps, makeArgs({ provider: "claude", model: "claude-opus-4-5", content: typed }))
707
+
708
+ expect(sentPrompts[0]).toContain("beaconId beacon-1")
709
+ expect(appended.find((entry) => entry.kind === "user_prompt")?.content).toBe(typed)
710
+ })
711
+
712
+ test("an unknown handle adds nothing to the provider prompt", async () => {
713
+ const { deps, sentPrompts } = makeBeaconDeps()
714
+
715
+ await startTurnForChat(deps, makeArgs({ provider: "claude", model: "claude-opus-4-5", content: "ping @beacon/ghost" }))
716
+
717
+ expect(sentPrompts[0]).toBe("ping @beacon/ghost")
718
+ })
719
+
720
+ test("a provider without beacon tools never receives the beacon block", async () => {
721
+ const { deps } = makeBeaconDeps()
722
+ const startTurn = deps.codexManager.startTurn as ReturnType<typeof mock>
723
+
724
+ await startTurnForChat(deps, makeArgs({ provider: "codex", model: "gpt-4o", content: "run it on @beacon/office-mac" }))
725
+
726
+ expect(JSON.stringify(startTurn.mock.calls[0])).not.toContain("beaconId")
727
+ })
728
+ })
@@ -12,6 +12,7 @@ import type {
12
12
  } from "./claude-turn-starter-types"
13
13
  import { OAuthPoolUnavailableError } from "./oauth-errors"
14
14
  import { buildPromptText } from "./claude-prompt-helpers"
15
+ import { withBeaconMentions } from "./beacon-mention-prompt"
15
16
  import { buildHistoryPrimer, shouldInjectPrimer } from "./history-primer"
16
17
  import { fallbackTitleFromMessage } from "./generate-title"
17
18
  import { parseMentions, type ParsedMention } from "./mention-parser"
@@ -299,7 +300,10 @@ async function startTurnAfterTurnStarted(
299
300
  targetProvider,
300
301
  Boolean(args.userClearedContext),
301
302
  )
302
- const userPromptText = buildPromptText(args.promptOverride ?? args.content, args.attachments)
303
+ const typedPromptText = buildPromptText(args.promptOverride ?? args.content, args.attachments)
304
+ const userPromptText = providerUsesSdkSession(args.provider)
305
+ ? withBeaconMentions(typedPromptText, args.content, deps.getBeacons(), deps.isBeaconOnline)
306
+ : typedPromptText
303
307
  const primer = shouldPrime
304
308
  ? buildHistoryPrimer(loadExistingMessages(), targetProvider, userPromptText)
305
309
  : null
@@ -97,6 +97,7 @@ function connectBeacon(handleRequest: (frame: Extract<BeaconFrame, { kind: "requ
97
97
  protocolVersion: TRANSFER_PROTOCOL,
98
98
  scope: DEFAULT_BEACON_SCOPE,
99
99
  socket: {
100
+ close(): void {},
100
101
  send(payload: SendPayload): number {
101
102
  const frame: BeaconFrame = JSON.parse(String(payload))
102
103
  sent.push(frame)
@@ -67,6 +67,7 @@ function connectedRegistry(responder: Responder): { registry: BeaconRegistry; se
67
67
  protocolVersion: 1,
68
68
  scope: DEFAULT_BEACON_SCOPE,
69
69
  socket: {
70
+ close(): void {},
70
71
  send(payload: SendPayload): number {
71
72
  const frame: BeaconFrame = JSON.parse(String(payload))
72
73
  sent.push(frame)
@@ -133,7 +134,9 @@ describe("beacon agent tools", () => {
133
134
  const tools = toolsFor(registry, beaconConfig({}))
134
135
  const result = await tools.get("beacon_list")!.handler({})
135
136
  expect(JSON.parse(textOf(result))).toEqual({
136
- beacons: [{ id: BEACON_ID, label: LABEL, os: "linux", online: true, enabled: true }],
137
+ beacons: [
138
+ { id: BEACON_ID, label: LABEL, alias: null, mention: "@beacon/build-box", os: "linux", online: true, enabled: true },
139
+ ],
137
140
  })
138
141
  expect(sent).toEqual([])
139
142
  })
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"
2
2
  import { z } from "zod"
3
3
  import type { BeaconConfig } from "../shared/beacon-config"
4
4
  import type { BeaconRequest } from "../shared/beacon-protocol"
5
+ import { beaconDisplayName, buildBeaconMentionIndex, BEACON_MENTION_PREFIX } from "../shared/beacon-alias"
5
6
  import { buildBeaconStatusRows } from "../shared/beacon-status"
6
7
  import { buildBeaconRequest } from "../shared/beacon-tool-request"
7
8
  import type { BeaconToolOp } from "../shared/tool-call-types"
@@ -55,8 +56,8 @@ export interface BeaconToolContext {
55
56
  }
56
57
 
57
58
  const LIST_DESCRIPTION =
58
- "List the user's paired machines (beacons) with their id, label, OS, online state and whether they are enabled. "
59
- + "Metadata only: nothing is sent to any machine."
59
+ "List the user's paired machines (beacons) with their id, label, alias, @beacon mention handle, OS, online state and "
60
+ + "whether they are enabled. Metadata only: nothing is sent to any machine."
60
61
 
61
62
  const READ_DESCRIPTION =
62
63
  "Read a window of a text file on a beacon (a paired remote machine). Returns at most `limit` bytes starting at `offset` "
@@ -152,7 +153,7 @@ function toJsonObject(input: ToolArgs): JsonObject {
152
153
  }
153
154
 
154
155
  function describeBeacon(beacon: BeaconConfig): string {
155
- return `${beacon.label} (${beacon.id})`
156
+ return `${beaconDisplayName(beacon)} (${beacon.id})`
156
157
  }
157
158
 
158
159
  function formatStream(name: string, text: string): string {
@@ -244,8 +245,18 @@ export function buildBeaconToolList<TTool, TExtra>(
244
245
  return [
245
246
  ...transferTools,
246
247
  tool("beacon_list", LIST_DESCRIPTION, {}, async () => {
247
- const rows = buildBeaconStatusRows(deps.getBeacons(), registry.live())
248
- const summary = rows.map((row) => ({ id: row.id, label: row.label, os: row.os, online: row.online, enabled: row.enabled }))
248
+ const beacons = deps.getBeacons()
249
+ const { handleByBeaconId } = buildBeaconMentionIndex(beacons)
250
+ const rows = buildBeaconStatusRows(beacons, registry.live())
251
+ const summary = rows.map((row) => ({
252
+ id: row.id,
253
+ label: row.label,
254
+ alias: row.alias,
255
+ mention: `@${BEACON_MENTION_PREFIX}${handleByBeaconId.get(row.id) ?? row.id}`,
256
+ os: row.os,
257
+ online: row.online,
258
+ enabled: row.enabled,
259
+ }))
249
260
  return ok(JSON.stringify({ beacons: summary }))
250
261
  }),
251
262
  tool(
@@ -23,7 +23,7 @@ import {
23
23
  } from "../../shared/chat-tasks/types"
24
24
 
25
25
  export const TASK_CREATE_DESCRIPTION =
26
- "Add a task to THIS chat's durable task list. Unlike the CLI's own TaskCreate, this survives /clear, compaction and a server restart, and a subagent's call lands on the parent chat — which is what makes it usable as an autonomous loop's plan. Pass needs to declare dependencies, and worktree/branch when the task will be worked in its own git worktree."
26
+ "Add a task to THIS chat's durable task list. Unlike the CLI's own TaskCreate, this survives /clear, compaction and a server restart, and a subagent's call lands on the parent chat — which is what makes it usable as an autonomous loop's plan. Pass needs to declare dependencies, and worktree/branch when the task will be worked in its own git worktree. The list shows tasks in the order they were created and offers no way to reorder them, and parallel task_create calls are recorded in arbitrary order, so create tasks one call at a time in the order they should be listed."
27
27
 
28
28
  export const TASK_UPDATE_DESCRIPTION =
29
29
  "Change a durable task: its status (pending / in_progress / completed), subject, description, dependencies or worktree. Replacing a task's status is how progress is recorded — there is no log to append to and no way for completed work to pile up and be redone."
@@ -41,6 +41,7 @@ function chat(overrides: Partial<ObservedChat> = {}): ObservedChat {
41
41
  projectTitle: "P",
42
42
  chatTitle: "Hello",
43
43
  status: "idle",
44
+ hasPendingWork: false,
44
45
  ...overrides,
45
46
  }
46
47
  }
@@ -99,6 +100,44 @@ describe("PushManager.observeStatuses", () => {
99
100
  expect(JSON.parse(sender.sent[0].payload).kind).toBe("completed")
100
101
  })
101
102
 
103
+ test("holds completed while background work is pending and sends it once the work ends", async () => {
104
+ await registerSub(manager, store, "d1", "https://push.example/x")
105
+ await manager.observeStatuses([chat({ status: "running" })])
106
+ await manager.observeStatuses([chat({ status: "idle", hasPendingWork: true })])
107
+ expect(sender.sent).toHaveLength(0)
108
+ await manager.observeStatuses([chat({ status: "idle", hasPendingWork: false })])
109
+ await manager.observeStatuses([chat({ status: "idle", hasPendingWork: false })])
110
+ expect(sender.sent).toHaveLength(1)
111
+ expect(JSON.parse(sender.sent[0].payload).kind).toBe("completed")
112
+ })
113
+
114
+ test("does not send done when the turn was cancelled", async () => {
115
+ await registerSub(manager, store, "d1", "https://push.example/x")
116
+ await manager.observeStatuses([chat({ status: "running" })])
117
+ manager.noteTurnOutcome("c1", "cancelled")
118
+ await manager.observeStatuses([chat({ status: "idle" })])
119
+ expect(sender.sent).toHaveLength(0)
120
+ })
121
+
122
+ test("a later idle after a suppressed cancel still sends done", async () => {
123
+ await registerSub(manager, store, "d1", "https://push.example/x")
124
+ await manager.observeStatuses([chat({ status: "running" })])
125
+ manager.noteTurnOutcome("c1", "cancelled")
126
+ await manager.observeStatuses([chat({ status: "idle" })])
127
+ await manager.observeStatuses([chat({ status: "running" })])
128
+ await manager.observeStatuses([chat({ status: "idle" })])
129
+ expect(sender.sent).toHaveLength(1)
130
+ expect(JSON.parse(sender.sent[0].payload).kind).toBe("completed")
131
+ })
132
+
133
+ test("still notifies waiting_for_user while background work is pending", async () => {
134
+ await registerSub(manager, store, "d1", "https://push.example/x")
135
+ await manager.observeStatuses([chat({ status: "running", hasPendingWork: true })])
136
+ await manager.observeStatuses([chat({ status: "waiting_for_user", hasPendingWork: true })])
137
+ expect(sender.sent).toHaveLength(1)
138
+ expect(JSON.parse(sender.sent[0].payload).kind).toBe("waiting_for_user")
139
+ })
140
+
102
141
  test("fires for any -> failed", async () => {
103
142
  await registerSub(manager, store, "d1", "https://push.example/x")
104
143
  await manager.observeStatuses([chat({ status: "running" })])
@@ -12,12 +12,15 @@ import type { VapidKeypair } from "./vapid.adapter"
12
12
 
13
13
  export type { PushPayload, PushTransitionKind } from "../../shared/types"
14
14
 
15
+ export type TurnOutcome = "finished" | "failed" | "cancelled"
16
+
15
17
  export interface ObservedChat {
16
18
  chatId: string
17
19
  projectLocalPath: string
18
20
  projectTitle: string
19
21
  chatTitle: string
20
22
  status: KannaStatus
23
+ hasPendingWork: boolean
21
24
  }
22
25
 
23
26
  export interface WebPushSendOptions {
@@ -63,6 +66,8 @@ export class PushManager {
63
66
  private readonly mutedProjects = new Set<string>()
64
67
  private readonly mutedChats = new Set<string>()
65
68
  private readonly lastStatusByChat = new Map<string, KannaStatus>()
69
+ private readonly heldCompletions = new Set<string>()
70
+ private readonly latestOutcomeByChat = new Map<string, TurnOutcome>()
66
71
  private seeded = false
67
72
  private readonly dedupKeyToTs = new Map<string, number>()
68
73
  private readonly focusedByDevice = new Map<string, string | null>()
@@ -277,6 +282,10 @@ export class PushManager {
277
282
  }
278
283
  }
279
284
 
285
+ noteTurnOutcome(chatId: string, outcome: TurnOutcome): void {
286
+ this.latestOutcomeByChat.set(chatId, outcome)
287
+ }
288
+
280
289
  async observeStatuses(snapshot: readonly ObservedChat[]): Promise<void> {
281
290
  if (!this.seeded) {
282
291
  for (const chat of snapshot) {
@@ -288,7 +297,7 @@ export class PushManager {
288
297
  for (const chat of snapshot) {
289
298
  const prev = this.lastStatusByChat.get(chat.chatId)
290
299
  this.lastStatusByChat.set(chat.chatId, chat.status)
291
- const kind = this.detectTransition(prev, chat.status)
300
+ const kind = this.resolveKind(chat, prev)
292
301
  if (!kind) continue
293
302
  if (this.isDuplicate(chat.chatId, kind)) continue
294
303
  if (this.mutedProjects.has(chat.projectLocalPath)) continue
@@ -298,6 +307,41 @@ export class PushManager {
298
307
  }
299
308
  }
300
309
 
310
+ private resolveKind(chat: ObservedChat, prev: KannaStatus | undefined): PushTransitionKind | null {
311
+ const cancelled = this.latestOutcomeByChat.get(chat.chatId) === "cancelled"
312
+ const kind = this.resolveStatusKind(chat, prev)
313
+ if (chat.status !== "running" && chat.status !== "starting") {
314
+ this.latestOutcomeByChat.delete(chat.chatId)
315
+ }
316
+ if (cancelled && chat.status === "idle") {
317
+ this.heldCompletions.delete(chat.chatId)
318
+ return null
319
+ }
320
+ return kind
321
+ }
322
+
323
+ private resolveStatusKind(chat: ObservedChat, prev: KannaStatus | undefined): PushTransitionKind | null {
324
+ const kind = this.detectTransition(prev, chat.status)
325
+ if (chat.status === "waiting_for_user" || chat.status === "failed") {
326
+ this.heldCompletions.delete(chat.chatId)
327
+ return kind
328
+ }
329
+ if (kind === "completed" && chat.hasPendingWork) {
330
+ this.heldCompletions.add(chat.chatId)
331
+ return null
332
+ }
333
+ const releasesHeldCompletion =
334
+ kind === null
335
+ && chat.status === "idle"
336
+ && !chat.hasPendingWork
337
+ && this.heldCompletions.has(chat.chatId)
338
+ if (kind === "completed" || releasesHeldCompletion) {
339
+ this.heldCompletions.delete(chat.chatId)
340
+ return "completed"
341
+ }
342
+ return kind
343
+ }
344
+
301
345
  private detectTransition(
302
346
  prev: KannaStatus | undefined,
303
347
  next: KannaStatus,
@@ -395,6 +395,7 @@ async function createApplicationServices(options: StartKannaServerOptions): Prom
395
395
  subagentTranscriptRegistry,
396
396
  localCatalog,
397
397
  genuiDatasets,
398
+ onTurnOutcome: (chatId, outcome) => pushManager.noteTurnOutcome(chatId, outcome),
398
399
  chatPolicy: { ...POLICY_DEFAULT, defaultAction: "auto-allow" },
399
400
  getSubagents: () => appSettings.getSnapshot().subagents,
400
401
  getAppSettingsSnapshot: () => buildAgentAppSettingsView(appSettings.getSnapshot()),
@@ -110,7 +110,7 @@ export class BroadcastManager {
110
110
  for (const ws of this.sockets) {
111
111
  const snapshotSignatures = ensureSnapshotSignatures(ws)
112
112
  for (const [id, topic] of ws.data.subscriptions.entries()) {
113
- if (topic.type !== "app-settings") continue
113
+ if (topic.type !== "app-settings" && topic.type !== "beacons") continue
114
114
  const envelope = deps.envelopeBuilder.createEnvelope(id, topic, undefined, ws)
115
115
  if (envelope.type !== "snapshot") continue
116
116
  const signature = JSON.stringify(envelope.snapshot)
@@ -18,6 +18,7 @@ import type { BackgroundTaskOutputRegistry } from "./background-task-output-regi
18
18
  import type { BoardRegistry } from "./board-registry"
19
19
  import type { BeaconRegistry } from "./beacon-registry"
20
20
  import { buildBeaconStatusRows } from "../shared/beacon-status"
21
+ import { chatHasPendingWork } from "../shared/chat-activity"
21
22
  import type { FollowedSessionRegistry } from "./followed-session-registry"
22
23
  import type { UpdateManager } from "./update-manager"
23
24
  import type { PackageUpdateManager } from "./package-update-manager"
@@ -108,6 +109,7 @@ function buildSidebarSnapshotCacheEntry(
108
109
  projectTitle: group.localPath.split("/").filter(Boolean).pop() ?? group.localPath,
109
110
  chatTitle: chat.title,
110
111
  status: chat.status,
112
+ hasPendingWork: chatHasPendingWork(chat.activity),
111
113
  }))
112
114
  )
113
115
  void pushManager.observeStatuses(observed).catch((error) => {
@@ -193,6 +193,7 @@ describe("beacons.update", () => {
193
193
  sent.push(String(payload))
194
194
  return sent.length
195
195
  },
196
+ close(): void {},
196
197
  }
197
198
  }
198
199
 
@@ -26,6 +26,9 @@ import { createBoardRegistry } from "./board-registry"
26
26
  import { createBoardStore } from "./board-store.adapter"
27
27
  import { POLICY_DEFAULT } from "../shared/permission-policy"
28
28
  import { DEFAULT_TAB_MIN_WIDTH } from "../shared/pane-tab-width"
29
+ import type { BeaconConfig } from "../shared/beacon-config"
30
+ import { DEFAULT_BEACON_SCOPE } from "../shared/beacon-scope"
31
+ import { createBeaconRegistry } from "./beacon-registry"
29
32
 
30
33
  function withSidebarGroupDefaults(group: {
31
34
  groupKey: string
@@ -659,6 +662,71 @@ describe("ws-router", () => {
659
662
  ])
660
663
  })
661
664
 
665
+ test("a beacon removed in settings disappears from an open beacons subscription", async () => {
666
+ const stamp = "2026-10-13T00:00:00.000Z"
667
+ const beacon = (id: string): BeaconConfig => ({
668
+ id,
669
+ label: `machine-${id}`,
670
+ publicKey: "pk",
671
+ os: "linux",
672
+ scope: DEFAULT_BEACON_SCOPE,
673
+ enabled: true,
674
+ createdAt: stamp,
675
+ updatedAt: stamp,
676
+ })
677
+ let snapshot: AppSettingsSnapshot = { ...DEFAULT_APP_SETTINGS_SNAPSHOT, customBeacons: [beacon("b1"), beacon("b2")] }
678
+ let listener: ((nextSnapshot: AppSettingsSnapshot) => void) | null = null
679
+ const router = createWsRouter({
680
+ store: { state: createEmptyState() } as never,
681
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set(), getWaitStartedAtByChatId: () => new Map() } as never,
682
+ terminals: { getSnapshot: () => null, onEvent: () => () => {} } as never,
683
+ keybindings: { getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT, onChange: () => () => {} } as never,
684
+ appSettings: {
685
+ getSnapshot: () => snapshot,
686
+ write: async () => snapshot,
687
+ writePatch: async (patch) => {
688
+ const removedId = patch.customBeacons?.delete?.id
689
+ snapshot = { ...snapshot, customBeacons: snapshot.customBeacons.filter((entry) => entry.id !== removedId) }
690
+ listener?.(snapshot)
691
+ return snapshot
692
+ },
693
+ onChange: (nextListener) => {
694
+ listener = nextListener
695
+ return () => {
696
+ listener = null
697
+ }
698
+ },
699
+ },
700
+ beaconRegistry: createBeaconRegistry(),
701
+ refreshDiscovery: async () => [],
702
+ getDiscoveredProjects: () => [],
703
+ machineDisplayName: "Local Machine",
704
+ updateManager: null,
705
+ pushManager: NOOP_PUSH_MANAGER,
706
+ })
707
+ const ws = new FakeWebSocket()
708
+ router.handleOpen(ws as never)
709
+ await router.handleMessage(
710
+ ws as never,
711
+ JSON.stringify({ v: 1, type: "subscribe", id: "beacons-sub-1", topic: { type: "beacons" } })
712
+ )
713
+ await router.handleMessage(
714
+ ws as never,
715
+ JSON.stringify({
716
+ v: 1,
717
+ type: "command",
718
+ id: "beacon-delete-1",
719
+ command: { type: "settings.writeAppSettingsPatch", patch: { customBeacons: { delete: { id: "b2" } } } },
720
+ })
721
+ )
722
+
723
+ const beaconIdsPushed = ws.sent.flatMap((envelope) => {
724
+ const pushed = envelope as { id?: string; snapshot?: { type?: string; data?: { beacons?: Array<{ id: string }> } } }
725
+ return pushed.id === "beacons-sub-1" ? [pushed.snapshot?.data?.beacons?.map((row) => row.id) ?? []] : []
726
+ })
727
+ expect(beaconIdsPushed).toEqual([["b1", "b2"], ["b1"]])
728
+ })
729
+
662
730
  test("tracks analytics preference transitions in the correct order", async () => {
663
731
  const analyticsEvents: string[] = []
664
732
  let analyticsEnabled = true