@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.
- package/dist/client/assets/{BoardsPage-BvYUo0PD.js → BoardsPage-e9KN-PqL.js} +1 -1
- package/dist/client/assets/{BoardsRoutePage-D5hLNJ2q.js → BoardsRoutePage-BfAUEg94.js} +1 -1
- package/dist/client/assets/ChatPage-CoLgIycl.js +93 -0
- package/dist/client/assets/{CronJobRow-VCv0jyI2.js → CronJobRow-R6sGCVRD.js} +1 -1
- package/dist/client/assets/{CronJobsPage-8FE4cO2q.js → CronJobsPage-DK-B2TNe.js} +1 -1
- package/dist/client/assets/{FilePreviewSheet-B5b8L-4e.js → FilePreviewSheet-O3nyFK2i.js} +2 -2
- package/dist/client/assets/{FlowSurface-DmNZQO7Z.js → FlowSurface-De4fzKB9.js} +1 -1
- package/dist/client/assets/{GenUIBlock-DX1nLbtf.js → GenUIBlock-D6oiFJaL.js} +2 -2
- package/dist/client/assets/{KannaSocketProvider-CiuKyRYE.js → KannaSocketProvider-BHItG6ob.js} +1 -1
- package/dist/client/assets/{LocalProjectsPage-DjCZmQ8c.js → LocalProjectsPage-C4YvkutW.js} +1 -1
- package/dist/client/assets/SettingsPage-DuDvIWI7.js +13 -0
- package/dist/client/assets/{StackBoardsRoutePage-i7WMqsk0.js → StackBoardsRoutePage-CEaF8HQA.js} +1 -1
- package/dist/client/assets/{ToolCallMessage-BhxowUjs.js → ToolCallMessage-CCR_uV98.js} +1 -1
- package/dist/client/assets/{VChartSurface-DQ6HIQXe.js → VChartSurface-JKYw0D-r.js} +1 -1
- package/dist/client/assets/{WorkflowsPage-BCIsFa6f.js → WorkflowsPage-B1ZvlJZE.js} +1 -1
- package/dist/client/assets/{WorkflowsSection-B5TkVqRA.js → WorkflowsSection-CCRo-MxO.js} +1 -1
- package/dist/client/assets/{chart-renderer-DQLdORyQ.js → chart-renderer-BM8heEao.js} +1 -1
- package/dist/client/assets/{chatSounds-CUN4vZG0.js → chatSounds-BmmQ91hl.js} +1 -1
- package/dist/client/assets/chevron-left-yjfH1ZSB.js +1 -0
- package/dist/client/assets/{clipboard.adapter-BvW6oQa_.js → clipboard.adapter-CJ_H99S1.js} +1 -1
- package/dist/client/assets/{columnStyle-C0IvZxLY.js → columnStyle-Dn_3F527.js} +1 -1
- package/dist/client/assets/{contextWindow-WJUHfP_d.js → contextWindow-BA0xOQMO.js} +1 -1
- package/dist/client/assets/copyFeedbackStore-CUQsds64.js +1 -0
- package/dist/client/assets/{createScopedStore-DW4wfjbM.js → createScopedStore-DqP7mPc0.js} +1 -1
- package/dist/client/assets/{dist-Q8elClTa.js → dist-ChIeJWx6.js} +1 -1
- package/dist/client/assets/{ellipsis-D_1qLZbX.js → ellipsis-hKiqLkY4.js} +1 -1
- package/dist/client/assets/{flow-kinds-Df0GmP61.js → flow-kinds-C9nOfZHH.js} +1 -1
- package/dist/client/assets/{folder-UHukK2MD.js → folder-BiZuYd4N.js} +1 -1
- package/dist/client/assets/{formatDuration-BA1K5CFs.js → formatDuration-C6zrNbeh.js} +1 -1
- package/dist/client/assets/{genui-CZI72r1O.js → genui-CnB2lRaB.js} +1 -1
- package/dist/client/assets/{globe-CQI7GjsK.js → globe-D4_HmNVj.js} +1 -1
- package/dist/client/assets/{host-C0p_56Re.js → host-B3gUJGiJ.js} +1 -1
- package/dist/client/assets/index-BYus2o1t.css +1 -0
- package/dist/client/assets/{index-3lHvRekg.js → index-uQsXmgHP.js} +3 -3
- package/dist/client/assets/{info-BXzVPePc.js → info-B_Vqy-5o.js} +1 -1
- package/dist/client/assets/{input-BerPPEIs.js → input-DRve70PS.js} +1 -1
- package/dist/client/assets/{lexicalToReact-DdXqoi0d.js → lexicalToReact-BFuz5nt-.js} +3 -3
- package/dist/client/assets/{log-7fGlFSON.js → log-DWUXpF9D.js} +1 -1
- package/dist/client/assets/{monitor-BOFtyQiW.js → monitor-DDe-a8Wg.js} +1 -1
- package/dist/client/assets/{pendingActionsStore-idc6m2Ya.js → pendingActionsStore-in-8OIop.js} +1 -1
- package/dist/client/assets/{play-DltDjX9H.js → play-CCpwuwmW.js} +1 -1
- package/dist/client/assets/{plus-CwWMJ0a-.js → plus-DF1Xx1dC.js} +1 -1
- package/dist/client/assets/{popover-gn1uvc-1.js → popover-CuSv5Wru.js} +1 -1
- package/dist/client/assets/{pushClient-NeXrldi-.js → pushClient-DtF2D2cW.js} +1 -1
- package/dist/client/assets/runDetached-B5RMTcFn.js +1 -0
- package/dist/client/assets/{segmented-control-3UDSwswP.js → segmented-control-2bnq74MU.js} +1 -1
- package/dist/client/assets/{select-Df67vheJ.js → select-BZMQ2erm.js} +1 -1
- package/dist/client/assets/{settings-header-button-CKZHCdXh.js → settings-header-button-Df7-MZFW.js} +1 -1
- package/dist/client/assets/{sidebarPendingActions-B8iNQJfE.js → sidebarPendingActions-DPIIvMHh.js} +1 -1
- package/dist/client/assets/{square-pen-DJjPW4f4.js → square-pen-CKf_gOOo.js} +1 -1
- package/dist/client/assets/{state-mark-CU2hw5D9.js → state-mark-DVh0A9N6.js} +1 -1
- package/dist/client/assets/{terminal-8Bxl5uZh.js → terminal-CHsxiDwH.js} +1 -1
- package/dist/client/assets/{timer.adapter-BD7zwXEm.js → timer.adapter-Besjjb6B.js} +1 -1
- package/dist/client/assets/{tools-Lo2VrFmM.js → tools-B1sdThgL.js} +1 -1
- package/dist/client/assets/{triangle-alert-CQwDyl4K.js → triangle-alert-DlaH1f-p.js} +1 -1
- package/dist/client/assets/{useAppGlobalState-uP5C1BG8.js → useAppGlobalState-cMGGvgsS.js} +1 -1
- package/dist/client/assets/{useKannaState-B2eDC-Dw.js → useKannaState-gc-Chi9u.js} +1 -1
- package/dist/client/assets/{useTheme-B_JDh31l.js → useTheme-B1rO5zPx.js} +1 -1
- package/dist/client/index.html +34 -34
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +1 -0
- package/src/server/agent-coordinator.ts +4 -0
- package/src/server/beacon-mention-prompt.test.ts +33 -0
- package/src/server/beacon-mention-prompt.ts +33 -0
- package/src/server/beacon-registry.test.ts +1 -0
- package/src/server/beacon-registry.ts +9 -1
- package/src/server/beacon-services.test.ts +11 -1
- package/src/server/beacon-services.ts +4 -0
- package/src/server/beacon-settings.test.ts +74 -1
- package/src/server/beacon-settings.ts +54 -3
- package/src/server/claude-turn-starter-types.ts +3 -0
- package/src/server/claude-turn-starter.test.ts +85 -0
- package/src/server/claude-turn-starter.ts +5 -1
- package/src/server/kanna-mcp-beacon-transfer.test.ts +1 -0
- package/src/server/kanna-mcp-beacon.test.ts +4 -1
- package/src/server/kanna-mcp-beacon.ts +16 -5
- package/src/server/kanna-mcp-tools/chat-tasks.ts +1 -1
- package/src/server/push/push-manager.test.ts +39 -0
- package/src/server/push/push-manager.ts +45 -1
- package/src/server/server.ts +1 -0
- package/src/server/ws-router-broadcast.ts +1 -1
- package/src/server/ws-router-envelope.ts +2 -0
- package/src/server/ws-router-settings.test.ts +1 -0
- package/src/server/ws-router.test.ts +68 -0
- package/src/shared/beacon-alias.test.ts +64 -0
- package/src/shared/beacon-alias.ts +120 -0
- package/src/shared/beacon-config.ts +2 -0
- package/src/shared/beacon-status.test.ts +2 -0
- package/src/shared/beacon-status.ts +2 -0
- package/src/shared/chat-activity.ts +8 -0
- package/dist/client/assets/ChatPage-zfy8nznW.js +0 -93
- package/dist/client/assets/SettingsPage-ELjbPQEw.js +0 -13
- package/dist/client/assets/chevron-left-DpI8tMxJ.js +0 -1
- package/dist/client/assets/copyFeedbackStore-BsjXKW0-.js +0 -1
- package/dist/client/assets/index-D8o_YX_z.css +0 -1
- 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
|
|
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
|
-
...
|
|
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
|
|
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: [
|
|
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
|
|
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
|
|
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
|
|
248
|
-
const
|
|
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.
|
|
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,
|
package/src/server/server.ts
CHANGED
|
@@ -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) => {
|
|
@@ -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
|