@adhdev/mesh-shared 1.0.82-rc.34 → 1.0.82-rc.36
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/assistant-protocol.d.ts +7 -2
- package/dist/decision-push-relay.d.ts +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +425 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +388 -11
- package/dist/index.mjs.map +1 -1
- package/dist/mission-items.d.ts +202 -0
- package/dist/protocol/daemon-server.d.ts +27 -2
- package/dist/protocol/dashboard-server.d.ts +2 -2
- package/dist/session-launch.d.ts +7 -0
- package/dist/turn-ipc-mission.d.ts +59 -1
- package/dist/worker-protocol.d.ts +1 -1
- package/package.json +1 -1
- package/src/assistant-protocol.ts +29 -2
- package/src/decision-push-relay.ts +23 -0
- package/src/index.ts +2 -0
- package/src/mission-items.ts +421 -0
- package/src/protocol/daemon-server.ts +26 -0
- package/src/session-launch.ts +9 -0
- package/src/turn-ipc-mission.ts +108 -9
- package/src/worker-protocol.ts +4 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mission-items — structured mission records: the item kinds a mission carries
|
|
3
|
+
* (remaining work, live checks, owner decisions, evidence), their normalization
|
|
4
|
+
* and limits, dedupe-key derivation and the close outcome enum
|
|
5
|
+
* (docs/design/2026-10-28-structured-missions-and-owner-decisions.md §4, §5).
|
|
6
|
+
*
|
|
7
|
+
* Philosophy (owner 2026-10-28, design §0): NEVER reject for size or shape —
|
|
8
|
+
* over-long text is truncated, extra refs are dropped, unusable items are
|
|
9
|
+
* reported per-item. The only hard rule lives elsewhere (the daemon): a
|
|
10
|
+
* coordinator or worker can never ANSWER a decision, so `answer`/`delivery`
|
|
11
|
+
* are stripped from every caller-supplied `decision` here.
|
|
12
|
+
*
|
|
13
|
+
* Dependency-free leaf: pure functions over plain data, no storage.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const MISSION_ITEM_KINDS = ['remaining', 'live_check', 'decision', 'evidence'] as const
|
|
17
|
+
export type MissionItemKind = typeof MISSION_ITEM_KINDS[number]
|
|
18
|
+
|
|
19
|
+
export const MISSION_ITEM_STATES = ['open', 'done', 'dropped', 'moved', 'answered', 'expired', 'withdrawn', 'superseded'] as const
|
|
20
|
+
export type MissionItemState = typeof MISSION_ITEM_STATES[number]
|
|
21
|
+
|
|
22
|
+
/** States a coordinator/worker may SET through items_update, per kind. `answered`/`expired` are daemon-owned. */
|
|
23
|
+
export const COORDINATOR_SETTABLE_ITEM_STATES: Readonly<Record<MissionItemKind, readonly MissionItemState[]>> = {
|
|
24
|
+
remaining: ['open', 'done', 'dropped'],
|
|
25
|
+
live_check: ['open', 'done', 'dropped'],
|
|
26
|
+
evidence: ['open', 'done', 'dropped'],
|
|
27
|
+
decision: ['open', 'withdrawn', 'superseded'],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const MISSION_OUTCOMES = ['merged', 'verified_live', 'superseded', 'not_needed', 'owner_abandoned', 'unspecified'] as const
|
|
31
|
+
export type MissionOutcome = typeof MISSION_OUTCOMES[number]
|
|
32
|
+
export function isMissionOutcome(value: unknown): value is MissionOutcome {
|
|
33
|
+
return typeof value === 'string' && (MISSION_OUTCOMES as readonly string[]).includes(value)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const MISSION_REF_KINDS = ['commit', 'branch', 'pr', 'task', 'artifact', 'mission', 'url'] as const
|
|
37
|
+
export type MissionRefKind = typeof MISSION_REF_KINDS[number]
|
|
38
|
+
|
|
39
|
+
export const MISSION_LIMITS = {
|
|
40
|
+
summary: 300,
|
|
41
|
+
outcomeReason: 500,
|
|
42
|
+
itemText: 300,
|
|
43
|
+
machine: 80,
|
|
44
|
+
refs: 8,
|
|
45
|
+
refValue: 300,
|
|
46
|
+
refRepo: 120,
|
|
47
|
+
refNote: 200,
|
|
48
|
+
dedupeKey: 400,
|
|
49
|
+
question: 300,
|
|
50
|
+
context: 600,
|
|
51
|
+
options: 5,
|
|
52
|
+
optionLabel: 60,
|
|
53
|
+
optionDetail: 200,
|
|
54
|
+
/** Open items per mission before the oldest `remaining` fold into a "N more" summary item. */
|
|
55
|
+
openItemsPerMission: 200,
|
|
56
|
+
/** One event payload (serialized). */
|
|
57
|
+
eventPayloadBytes: 16 * 1024,
|
|
58
|
+
/** items_add / items_update entries honoured per call (extra entries are reported, not applied). */
|
|
59
|
+
itemsPerCall: 50,
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
export const MISSION_DECISION_OPTION_IDS = ['a', 'b', 'c', 'd', 'e'] as const
|
|
63
|
+
export const MISSION_DECISION_URGENCIES = ['normal', 'blocking'] as const
|
|
64
|
+
export type MissionDecisionUrgency = typeof MISSION_DECISION_URGENCIES[number]
|
|
65
|
+
export const MISSION_DECISION_ON_DUE = ['remind', 'apply_recommended'] as const
|
|
66
|
+
export type MissionDecisionOnDue = typeof MISSION_DECISION_ON_DUE[number]
|
|
67
|
+
|
|
68
|
+
export interface MissionItemRef {
|
|
69
|
+
kind: MissionRefKind
|
|
70
|
+
repo?: string
|
|
71
|
+
value: string
|
|
72
|
+
note?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface MissionDecisionOption {
|
|
76
|
+
id: string
|
|
77
|
+
label: string
|
|
78
|
+
detail?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Who/where an answer came from. Written only by the daemon's (owner-side) answer path — never from tool input. */
|
|
82
|
+
export interface MissionDecisionAnswer {
|
|
83
|
+
optionId?: string
|
|
84
|
+
text?: string
|
|
85
|
+
by: 'owner' | 'auto_recommended'
|
|
86
|
+
via: 'dashboard' | 'assistant' | 'push' | 'cli' | 'auto'
|
|
87
|
+
interpretedFrom?: string
|
|
88
|
+
at: string
|
|
89
|
+
ingress?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MissionDecisionDelivery {
|
|
93
|
+
messageId: string
|
|
94
|
+
/**
|
|
95
|
+
* `queued`: the coordinator's input queue accepted it but has not typed it yet — the daemon
|
|
96
|
+
* confirms it against the send receipt (a daemon restart drops that queue). Daemon-written.
|
|
97
|
+
*/
|
|
98
|
+
state: 'pending' | 'queued' | 'sent' | 'acked'
|
|
99
|
+
lastAttemptAt?: string
|
|
100
|
+
/** Coordinator session + funnel messageId of a `queued` delivery (receipt lookup). Daemon-written. */
|
|
101
|
+
sessionId?: string
|
|
102
|
+
wireMessageId?: string
|
|
103
|
+
/** Failed send attempts of this revision (retry backoff). Daemon-written. */
|
|
104
|
+
attempts?: number
|
|
105
|
+
/** What the coordinator is told: a first answer, a changed answer, or an undone one. Daemon-written. */
|
|
106
|
+
kind?: 'answered' | 'changed' | 'undone'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface MissionDecision {
|
|
110
|
+
question: string
|
|
111
|
+
context?: string
|
|
112
|
+
options: MissionDecisionOption[]
|
|
113
|
+
recommended?: string
|
|
114
|
+
allowFreeText: boolean
|
|
115
|
+
dueAt?: string
|
|
116
|
+
onDue: MissionDecisionOnDue
|
|
117
|
+
urgency: MissionDecisionUrgency
|
|
118
|
+
answer?: MissionDecisionAnswer
|
|
119
|
+
delivery?: MissionDecisionDelivery
|
|
120
|
+
/**
|
|
121
|
+
* Coordinator-prompt watermark (bundle B): ISO time the coordinator last "saw" this item's
|
|
122
|
+
* answer. An answered decision is shown in the coordinator prompt while this is absent or
|
|
123
|
+
* older than `answer.at` (a changed answer re-surfaces). Written only by the daemon.
|
|
124
|
+
*/
|
|
125
|
+
promptAckedAt?: string
|
|
126
|
+
/** Counts answer writes (answer / change / undo); delivery messageIds are `decision:<itemId>:r<n>`. Daemon-written. */
|
|
127
|
+
answerRevision?: number
|
|
128
|
+
/** ISO time an `onDue: remind` crossing was recorded (once per dueAt). Daemon-written. */
|
|
129
|
+
remindedAt?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface NormalizedMissionItem {
|
|
133
|
+
kind: MissionItemKind
|
|
134
|
+
text: string
|
|
135
|
+
machine?: string
|
|
136
|
+
refs: MissionItemRef[]
|
|
137
|
+
decision?: MissionDecision
|
|
138
|
+
dedupeKey: string
|
|
139
|
+
/** Fields that were truncated/dropped — informational, never an error. */
|
|
140
|
+
truncated: string[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function truncateMissionText(value: string, max: number): string {
|
|
144
|
+
if (value.length <= max) return value
|
|
145
|
+
return `${value.slice(0, max - 1).trimEnd()}…`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function str(value: unknown): string {
|
|
149
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collapse(value: string): string {
|
|
153
|
+
return value.replace(/\s+/g, ' ').trim()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeMissionSummary(value: unknown): { value: string; truncated: boolean } | null {
|
|
157
|
+
if (typeof value !== 'string') return null
|
|
158
|
+
const text = collapse(value)
|
|
159
|
+
return { value: truncateMissionText(text, MISSION_LIMITS.summary), truncated: text.length > MISSION_LIMITS.summary }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function normalizeOutcomeReason(value: unknown): { value: string; truncated: boolean } | null {
|
|
163
|
+
if (typeof value !== 'string') return null
|
|
164
|
+
const text = value.trim()
|
|
165
|
+
return { value: truncateMissionText(text, MISSION_LIMITS.outcomeReason), truncated: text.length > MISSION_LIMITS.outcomeReason }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Refs: ≤8, `{kind, repo?, value, note?}`. Unusable entries are dropped; the 9th and later are dropped. */
|
|
169
|
+
export function normalizeMissionRefs(value: unknown): { refs: MissionItemRef[]; truncated: boolean } {
|
|
170
|
+
if (!Array.isArray(value)) return { refs: [], truncated: false }
|
|
171
|
+
const refs: MissionItemRef[] = []
|
|
172
|
+
let truncated = false
|
|
173
|
+
for (const raw of value) {
|
|
174
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
175
|
+
const r = raw as Record<string, unknown>
|
|
176
|
+
const kind = str(r.kind)
|
|
177
|
+
const refValue = str(r.value)
|
|
178
|
+
if (!(MISSION_REF_KINDS as readonly string[]).includes(kind) || !refValue) continue
|
|
179
|
+
if (refs.length >= MISSION_LIMITS.refs) { truncated = true; break }
|
|
180
|
+
const repo = str(r.repo)
|
|
181
|
+
const note = str(r.note)
|
|
182
|
+
refs.push({
|
|
183
|
+
kind: kind as MissionRefKind,
|
|
184
|
+
...(repo ? { repo: truncateMissionText(repo, MISSION_LIMITS.refRepo) } : {}),
|
|
185
|
+
value: truncateMissionText(refValue, MISSION_LIMITS.refValue),
|
|
186
|
+
...(note ? { note: truncateMissionText(note, MISSION_LIMITS.refNote) } : {}),
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return { refs, truncated }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizeOptions(value: unknown, truncated: string[]): MissionDecisionOption[] {
|
|
193
|
+
if (!Array.isArray(value)) return []
|
|
194
|
+
const out: MissionDecisionOption[] = []
|
|
195
|
+
for (const raw of value) {
|
|
196
|
+
if (typeof raw === 'string' && raw.trim()) {
|
|
197
|
+
if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
|
|
198
|
+
out.push({ id: MISSION_DECISION_OPTION_IDS[out.length], label: truncateMissionText(raw.trim(), MISSION_LIMITS.optionLabel) })
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
|
202
|
+
const o = raw as Record<string, unknown>
|
|
203
|
+
const label = collapse(str(o.label))
|
|
204
|
+
if (!label) continue
|
|
205
|
+
if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
|
|
206
|
+
const detail = str(o.detail)
|
|
207
|
+
// Ids are positional ('a'..'e') so a caller cannot collide or invent ids.
|
|
208
|
+
out.push({
|
|
209
|
+
id: MISSION_DECISION_OPTION_IDS[out.length],
|
|
210
|
+
label: truncateMissionText(label, MISSION_LIMITS.optionLabel),
|
|
211
|
+
...(detail ? { detail: truncateMissionText(detail, MISSION_LIMITS.optionDetail) } : {}),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return out
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeDueAt(value: unknown): string | undefined {
|
|
218
|
+
if (typeof value !== 'string' || !value.trim()) return undefined
|
|
219
|
+
const ms = Date.parse(value)
|
|
220
|
+
return Number.isNaN(ms) ? undefined : new Date(ms).toISOString()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Caller-supplied decision → stored shape. `answer`/`delivery` (and any other
|
|
225
|
+
* unknown key) are DROPPED: a coordinator/worker cannot pre-answer its own
|
|
226
|
+
* question. `recommended` must name one of the options, else it is dropped.
|
|
227
|
+
*/
|
|
228
|
+
export function normalizeDecisionInput(raw: Record<string, unknown>, truncated: string[]): MissionDecision | null {
|
|
229
|
+
const questionSource = collapse(str(raw.question) || str(raw.text))
|
|
230
|
+
if (!questionSource) return null
|
|
231
|
+
if (questionSource.length > MISSION_LIMITS.question) truncated.push('question')
|
|
232
|
+
const question = truncateMissionText(questionSource, MISSION_LIMITS.question)
|
|
233
|
+
const contextSource = str(raw.context)
|
|
234
|
+
if (contextSource.length > MISSION_LIMITS.context) truncated.push('context')
|
|
235
|
+
const options = normalizeOptions(raw.options, truncated)
|
|
236
|
+
const recommendedRaw = str(raw.recommended)
|
|
237
|
+
const recommended = recommendedRaw && options.some(o => o.id === recommendedRaw)
|
|
238
|
+
? recommendedRaw
|
|
239
|
+
// Also accept the option label (coordinators naturally write "recommended: Option A").
|
|
240
|
+
: options.find(o => recommendedRaw && o.label.toLowerCase() === recommendedRaw.toLowerCase())?.id
|
|
241
|
+
const dueAt = normalizeDueAt(raw.dueAt ?? raw.due_at)
|
|
242
|
+
const onDueRaw = str(raw.onDue ?? raw.on_due)
|
|
243
|
+
const urgencyRaw = str(raw.urgency)
|
|
244
|
+
const allowFreeTextRaw = raw.allowFreeText ?? raw.allow_free_text
|
|
245
|
+
return {
|
|
246
|
+
question,
|
|
247
|
+
...(contextSource ? { context: truncateMissionText(contextSource, MISSION_LIMITS.context) } : {}),
|
|
248
|
+
options,
|
|
249
|
+
...(recommended ? { recommended } : {}),
|
|
250
|
+
allowFreeText: typeof allowFreeTextRaw === 'boolean' ? allowFreeTextRaw : true,
|
|
251
|
+
...(dueAt ? { dueAt } : {}),
|
|
252
|
+
// apply_recommended without a recommended option has nothing to apply — fall back to remind.
|
|
253
|
+
onDue: onDueRaw === 'apply_recommended' && recommended ? 'apply_recommended' : 'remind',
|
|
254
|
+
urgency: urgencyRaw === 'blocking' ? 'blocking' : 'normal',
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Normalized text for dedupe: lower-case, whitespace-collapsed, trailing punctuation trimmed. */
|
|
259
|
+
export function normalizeForDedupe(text: string): string {
|
|
260
|
+
return collapse(text).toLowerCase().replace(/[\s.!?。]+$/u, '')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function deriveItemDedupeKey(kind: MissionItemKind, parts: { text?: string; machine?: string; refs?: readonly MissionItemRef[]; question?: string; explicit?: string }): string {
|
|
264
|
+
const explicit = str(parts.explicit)
|
|
265
|
+
if (explicit) return truncateMissionText(`k:${explicit}`, MISSION_LIMITS.dedupeKey)
|
|
266
|
+
if (kind === 'decision') return truncateMissionText(`decision:${normalizeForDedupe(parts.question ?? parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
|
|
267
|
+
if (kind === 'evidence') {
|
|
268
|
+
const ref = parts.refs?.[0]
|
|
269
|
+
if (ref) return truncateMissionText(`evidence:${ref.kind}:${ref.repo ?? ''}:${ref.value}`, MISSION_LIMITS.dedupeKey)
|
|
270
|
+
}
|
|
271
|
+
const machine = parts.machine ? `@${normalizeForDedupe(parts.machine)}` : ''
|
|
272
|
+
return truncateMissionText(`${kind}${machine}:${normalizeForDedupe(parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Normalize one caller-supplied `items_add[]` entry. Returns `null` + `error`
|
|
277
|
+
* only when nothing usable remains (no text/question) — everything else is
|
|
278
|
+
* clamped, never rejected.
|
|
279
|
+
*/
|
|
280
|
+
export function normalizeMissionItemInput(raw: unknown): { item: NormalizedMissionItem } | { error: string } {
|
|
281
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'item must be an object' }
|
|
282
|
+
const r = raw as Record<string, unknown>
|
|
283
|
+
const kind = str(r.kind) || 'remaining'
|
|
284
|
+
if (!(MISSION_ITEM_KINDS as readonly string[]).includes(kind)) {
|
|
285
|
+
return { error: `invalid kind '${kind}' (valid: ${MISSION_ITEM_KINDS.join(', ')})` }
|
|
286
|
+
}
|
|
287
|
+
const truncated: string[] = []
|
|
288
|
+
const { refs, truncated: refsTruncated } = normalizeMissionRefs(r.refs)
|
|
289
|
+
if (refsTruncated) truncated.push('refs')
|
|
290
|
+
const machineRaw = collapse(str(r.machine))
|
|
291
|
+
const machine = machineRaw ? truncateMissionText(machineRaw, MISSION_LIMITS.machine) : undefined
|
|
292
|
+
const explicitKey = str(r.dedupe_key ?? r.dedupeKey)
|
|
293
|
+
if (kind === 'decision') {
|
|
294
|
+
const decisionRaw = (r.decision && typeof r.decision === 'object' && !Array.isArray(r.decision) ? r.decision : r) as Record<string, unknown>
|
|
295
|
+
const decision = normalizeDecisionInput({ ...decisionRaw, question: decisionRaw.question ?? r.question ?? r.text }, truncated)
|
|
296
|
+
if (!decision) return { error: 'decision item needs a question' }
|
|
297
|
+
return {
|
|
298
|
+
item: {
|
|
299
|
+
kind,
|
|
300
|
+
text: decision.question,
|
|
301
|
+
...(machine ? { machine } : {}),
|
|
302
|
+
refs,
|
|
303
|
+
decision,
|
|
304
|
+
dedupeKey: deriveItemDedupeKey('decision', { question: decision.question, explicit: explicitKey }),
|
|
305
|
+
truncated,
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const textSource = collapse(str(r.text))
|
|
310
|
+
if (!textSource && !(kind === 'evidence' && refs.length > 0)) return { error: `${kind} item needs text` }
|
|
311
|
+
if (textSource.length > MISSION_LIMITS.itemText) truncated.push('text')
|
|
312
|
+
const text = truncateMissionText(textSource || `${refs[0].kind}: ${refs[0].value}`, MISSION_LIMITS.itemText)
|
|
313
|
+
return {
|
|
314
|
+
item: {
|
|
315
|
+
kind: kind as MissionItemKind,
|
|
316
|
+
text,
|
|
317
|
+
...(machine ? { machine } : {}),
|
|
318
|
+
refs,
|
|
319
|
+
dedupeKey: deriveItemDedupeKey(kind as MissionItemKind, { text, machine, refs, explicit: explicitKey }),
|
|
320
|
+
truncated,
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface MissionItemUpdateInput {
|
|
326
|
+
id: string
|
|
327
|
+
state?: MissionItemState
|
|
328
|
+
text?: string
|
|
329
|
+
machine?: string | null
|
|
330
|
+
refs?: MissionItemRef[]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Normalize one `items_update[]` entry (id required). The kind-specific state rule is applied by the daemon, which knows the stored kind. */
|
|
334
|
+
export function normalizeMissionItemUpdate(raw: unknown): { update: MissionItemUpdateInput; truncated: string[] } | { error: string } {
|
|
335
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'update must be an object' }
|
|
336
|
+
const r = raw as Record<string, unknown>
|
|
337
|
+
const id = str(r.id ?? r.item_id ?? r.itemId)
|
|
338
|
+
if (!id) return { error: 'update needs an item id' }
|
|
339
|
+
const truncated: string[] = []
|
|
340
|
+
const update: MissionItemUpdateInput = { id }
|
|
341
|
+
if (r.state !== undefined) {
|
|
342
|
+
const state = str(r.state)
|
|
343
|
+
if (!(MISSION_ITEM_STATES as readonly string[]).includes(state)) return { error: `invalid state '${state}'` }
|
|
344
|
+
update.state = state as MissionItemState
|
|
345
|
+
}
|
|
346
|
+
if (typeof r.text === 'string') {
|
|
347
|
+
const text = collapse(r.text)
|
|
348
|
+
if (text.length > MISSION_LIMITS.itemText) truncated.push('text')
|
|
349
|
+
if (text) update.text = truncateMissionText(text, MISSION_LIMITS.itemText)
|
|
350
|
+
}
|
|
351
|
+
if (r.machine === null) update.machine = null
|
|
352
|
+
else if (typeof r.machine === 'string') {
|
|
353
|
+
const machine = collapse(r.machine)
|
|
354
|
+
update.machine = machine ? truncateMissionText(machine, MISSION_LIMITS.machine) : null
|
|
355
|
+
}
|
|
356
|
+
if (r.refs !== undefined) {
|
|
357
|
+
const { refs, truncated: t } = normalizeMissionRefs(r.refs)
|
|
358
|
+
if (t) truncated.push('refs')
|
|
359
|
+
update.refs = refs
|
|
360
|
+
}
|
|
361
|
+
return { update, truncated }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Cap a serialized event payload at {@link MISSION_LIMITS.eventPayloadBytes}; over-size payloads become a stub that says so. */
|
|
365
|
+
export function boundEventPayload(payload: Record<string, unknown>): string {
|
|
366
|
+
let json = JSON.stringify(payload)
|
|
367
|
+
if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
|
|
368
|
+
// Shrink the largest string fields first (the previous goal text is the usual offender).
|
|
369
|
+
const shrunk: Record<string, unknown> = {}
|
|
370
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
371
|
+
shrunk[k] = typeof v === 'string' && v.length > 4000 ? `${v.slice(0, 4000)}…` : v
|
|
372
|
+
}
|
|
373
|
+
shrunk.payloadTruncated = true
|
|
374
|
+
json = JSON.stringify(shrunk)
|
|
375
|
+
if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
|
|
376
|
+
return JSON.stringify({ payloadTruncated: true, keys: Object.keys(payload).slice(0, 20) })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The carry-over mission's fixed title (design §5.1). */
|
|
380
|
+
export const CARRY_OVER_MISSION_TITLE = 'Carry-over — open items from closed missions'
|
|
381
|
+
/** The title earlier builds stored; existing DBs still hold it, so it keeps matching (no rename). */
|
|
382
|
+
export const LEGACY_CARRY_OVER_MISSION_TITLE = '이월 — 닫힌 미션에서 넘어온 항목'
|
|
383
|
+
/** Canonical goal text of a newly created carry-over mission. */
|
|
384
|
+
export const CARRY_OVER_MISSION_GOAL = 'Open items carried over from closed missions, and decisions requested without a mission. Managed automatically — do not close.'
|
|
385
|
+
/**
|
|
386
|
+
* THE one predicate for "is this the auto carry-over mission". The daemon's `is_carry_over`
|
|
387
|
+
* column (`isCarryOver`) is the source of truth; wire views that cannot carry the flag (older
|
|
388
|
+
* strict decoders reject unknown keys) fall back to the fixed title. Use this everywhere.
|
|
389
|
+
*/
|
|
390
|
+
export function isCarryOverMissionRecord(m: { isCarryOver?: boolean; title?: unknown } | null | undefined): boolean {
|
|
391
|
+
return !!m && (m.isCarryOver === true || isCarryOverMissionTitle(m.title))
|
|
392
|
+
}
|
|
393
|
+
/** The carry-over mission's title is fixed (the daemon ignores renames), so a title match identifies it on wire views that carry no flag. */
|
|
394
|
+
export function isCarryOverMissionTitle(title: unknown): boolean {
|
|
395
|
+
if (typeof title !== 'string') return false
|
|
396
|
+
const t = title.trim()
|
|
397
|
+
return t === CARRY_OVER_MISSION_TITLE || t === LEGACY_CARRY_OVER_MISSION_TITLE
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Prefix of the owner-confirmation decision a coordinator's `owner_abandoned` close creates. */
|
|
401
|
+
export const OWNER_ABANDON_CONFIRM_KEY_PREFIX = 'owner-abandon-confirm:'
|
|
402
|
+
|
|
403
|
+
export const OWNER_ABANDON_CONFIRM_QUESTION = "The coordinator closed this as 'owner abandoned' — correct?"
|
|
404
|
+
export const OWNER_ABANDON_CONFIRM_OPTIONS = ['Correct', 'Reopen'] as const
|
|
405
|
+
|
|
406
|
+
/** worker `report_completion.decisions_needed` cap. */
|
|
407
|
+
export const WORKER_DECISIONS_NEEDED_MAX = 3
|
|
408
|
+
/** worker `report_completion.commits` cap and shape. */
|
|
409
|
+
export const WORKER_COMMITS_MAX = 20
|
|
410
|
+
const SHA_RE = /^[0-9a-f]{7,64}$/i
|
|
411
|
+
export function normalizeReportCommits(value: unknown): string[] | null {
|
|
412
|
+
if (!Array.isArray(value)) return null
|
|
413
|
+
const out: string[] = []
|
|
414
|
+
for (const v of value) {
|
|
415
|
+
if (typeof v !== 'string') return null
|
|
416
|
+
const sha = v.trim()
|
|
417
|
+
if (!SHA_RE.test(sha)) return null
|
|
418
|
+
if (!out.includes(sha.toLowerCase())) out.push(sha.toLowerCase())
|
|
419
|
+
}
|
|
420
|
+
return out
|
|
421
|
+
}
|
|
@@ -52,6 +52,9 @@ export const DAEMON_STATUS_EVENT_NAMES = [
|
|
|
52
52
|
'agent:generating_completed',
|
|
53
53
|
'agent:stopped',
|
|
54
54
|
'monitor:no_progress',
|
|
55
|
+
// An owner decision (structured missions) needs the owner. Server-bound for the push only;
|
|
56
|
+
// the dashboard learns about decisions over P2P (`mission_decisions_list`).
|
|
57
|
+
'agent:decision_needed',
|
|
55
58
|
] as const
|
|
56
59
|
export type DaemonStatusEventName = typeof DAEMON_STATUS_EVENT_NAMES[number]
|
|
57
60
|
export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
|
|
@@ -62,6 +65,27 @@ export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
|
|
|
62
65
|
* (`modalMessage`/`modalButtons`, CLAUDE.md "Push notification exception").
|
|
63
66
|
* Mirror of daemon-core `DaemonStatusEventPayload`.
|
|
64
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* Owner-decision push payload (design §9, owner decision D1 2026-10-28). The ONLY mission text that
|
|
70
|
+
* reaches the server: the question (<=80 chars) and up to 3 option labels (<=24 chars). Never
|
|
71
|
+
* `context`, free-text answers, mission goal/summary or chat content.
|
|
72
|
+
*/
|
|
73
|
+
export type DecisionPushWire = {
|
|
74
|
+
meshId: string
|
|
75
|
+
missionId: string
|
|
76
|
+
itemId: string
|
|
77
|
+
question: string
|
|
78
|
+
options: Array<{ id: string; label: string }>
|
|
79
|
+
recommended?: string
|
|
80
|
+
urgency: 'normal' | 'blocking'
|
|
81
|
+
dueAt?: string
|
|
82
|
+
/** `new` = freshly asked; `remind` = a blocking decision crossed its due time. */
|
|
83
|
+
reason: 'new' | 'remind'
|
|
84
|
+
}
|
|
85
|
+
export const DECISION_PUSH_QUESTION_MAX = 80
|
|
86
|
+
export const DECISION_PUSH_OPTION_LABEL_MAX = 24
|
|
87
|
+
export const DECISION_PUSH_OPTIONS_MAX = 3
|
|
88
|
+
|
|
65
89
|
export type DaemonStatusEventWirePayload = {
|
|
66
90
|
event: DaemonStatusEventName
|
|
67
91
|
timestamp: number
|
|
@@ -78,6 +102,8 @@ export type DaemonStatusEventWirePayload = {
|
|
|
78
102
|
waitKind?: 'external'
|
|
79
103
|
surfaceHidden?: boolean
|
|
80
104
|
muted?: boolean
|
|
105
|
+
/** Present only on `agent:decision_needed`. */
|
|
106
|
+
decision?: DecisionPushWire
|
|
81
107
|
}
|
|
82
108
|
|
|
83
109
|
// ─── Status report (mirror of daemon-core `CloudStatusReportPayload`) ──────
|
package/src/session-launch.ts
CHANGED
|
@@ -101,6 +101,13 @@ export interface ModelSelection {
|
|
|
101
101
|
/** Last value the provider itself reported (statusline, native history). */
|
|
102
102
|
observed?: string
|
|
103
103
|
observedAt?: number
|
|
104
|
+
/**
|
|
105
|
+
* The provider SILENTLY substituted another model for the requested one
|
|
106
|
+
* (antigravity: `model X is not recognized … Using "Y" instead`). `observed`
|
|
107
|
+
* then holds the model actually running. P2P/launch-record only — the
|
|
108
|
+
* server path never carries it (see the content boundary above).
|
|
109
|
+
*/
|
|
110
|
+
fallback?: boolean
|
|
104
111
|
/** Every value change in order. The last entry is the value in force. */
|
|
105
112
|
history: ModelSelectionHistoryEntry[]
|
|
106
113
|
}
|
|
@@ -206,6 +213,7 @@ function parseModelSelection(raw: unknown): ModelSelection | undefined {
|
|
|
206
213
|
const current = readString(record.current)
|
|
207
214
|
const observed = readString(record.observed)
|
|
208
215
|
const observedAt = readFiniteNumber(record.observedAt)
|
|
216
|
+
const fallback = record.fallback === true
|
|
209
217
|
return {
|
|
210
218
|
source: isModelAxisSource(record.source) ? record.source : 'unspecified',
|
|
211
219
|
...(requested ? { requested } : {}),
|
|
@@ -214,6 +222,7 @@ function parseModelSelection(raw: unknown): ModelSelection | undefined {
|
|
|
214
222
|
...(current ? { current } : {}),
|
|
215
223
|
...(observed ? { observed } : {}),
|
|
216
224
|
...(observedAt !== undefined ? { observedAt } : {}),
|
|
225
|
+
...(fallback ? { fallback } : {}),
|
|
217
226
|
history: history.slice(-MODEL_SELECTION_HISTORY_LIMIT),
|
|
218
227
|
}
|
|
219
228
|
}
|