kinitro.ai agent harness for Claude: briefs a kinitro.ai domain agent from the platform (persona, standing rules, start, every turn, context checkpoint, after…

kinitro.ai lets business users work with AI agents that know their business, follow their procedures and keep their results in one governed place. This repository holds what you need to run a kinitro.ai agent in Claude.
| Path | What it is |
|---|---|
plugins/kinitro-ai/ | The Claude Code plugin kinitro-ai (version 0.12.0). It briefs your agent from kinitro.ai and mirrors each conversation turn into your domain. |
.claude-plugin/marketplace.json | The marketplace entry that lets Claude Code install the plugin. |
docs/ | Guides for users, setup people and partners. |
assets/ | The kinitro ai logo. |
You need three things:
kinitro_ai.git on the machine.Install the plugin once per machine, in a Claude Code session:
/plugin marketplace add ThorstenHornung/kinitro.ai
/plugin install kinitro-ai@kinitro-ai
The plugin is active at once and in every later session. Choose your domain with:
/kinitro
It opens the kinitro.ai domain selection and binds the session to the domain you choose (/kinitro verum binds directly). Step-by-step help: Getting started.
All instruction texts live in kinitro.ai and are edited by the domain owner. The plugin only decides when each section is handed to the agent.
| Moment | What is injected |
|---|---|
| First message of a conversation, and again after each compaction | Persona and Working rules |
| First prompt of a session | Session start |
| Every prompt | A one-line marker, the Every turn section and the agent's current todo list |
| First prompt and after a compaction | The agent's notes and a map of its memory topics |
| Context window reaches 70 % (and again every 5 points more) | Context nearly full |
| First prompt after a compaction | After compaction |
| While Claude compacts the conversation | Compaction instruction (goes to the summarizer) |
| End of every turn | Your prompt and the agent's final answer are written into the agent's chat in kinitro.ai |
Details: How it works and Plugin reference.
Partners build specialized agent solutions on kinitro.ai and offer them to customers, at a charge. A solution combines persona, procedures, knowledge, data model, views and documents. Commercial terms are agreed individually during the beta. See For partners. To get access, contact Thorsten Hornung, kinitro consulting, support@kinitro.ai.
Proprietary beta software. See LICENSE.
hooks/register.tsx 809 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { DomainChoice } from '../types'
4
5// kinitro-ai harness for Claude. All instruction TEXTS live in kinitro.ai; this file only decides WHEN
6// each section is injected. Layers G+M: the general briefing behind the domain's general-procedures edge;
7// layer A: the agent node's native instruction-* contents. Nothing is hard-wired to a domain.
8
9const SERVER = 'kinitro_ai'
10// Everything is resolved from the seat (current-seat): no domain codes are hard-wired.
11const GENERAL_EDGE = 'general-procedures' // association edge from the domain node to the general library section
12const GENERAL_BRIEFING_PREFIX = 'Briefing: kinitro.ai agents in Claude' // Document in that section: layers G + M
13const WARN_PCT = 70 // auto-compaction fires at 80 % in this environment
14const REWARN_STEP = 5 // warn again when the fill rises this many more points
15const MIRROR_PLATFORM = 'claude-code' // chat mirror (item 3413): turns go into the agent's kinitro.ai chat via mirror-messaging
16const OUTBOX_FILE = '.kinitro-ai-outbox.json' // every turn is written here first, then sent; resending is safe (the verb ignores a repeat)
17const INBOX_FILE = '.kinitro-ai-inbox.json' // new chat messages from kinitro.ai, told to the agent at its next prompt
18const BOX_MAX = 200
19const FETCH_MS = 60000
20const MAX_TEXT = 50000 // chars per prompt / answer sent to the chat
21const RETRY_MS = 20000 // retry a failed briefing load at most this often
22const LOG_FILE = 'kinitro-ai.log'
23const VERSION = '0.19.0'
24const PENDING_VIEW_FILE = '.kinitro-ai-pending' // written by the /kinitro fallback when the plugin was not running yet: finish the binding at the next prompt
25const REFRESH_MS = 24 * 3600 * 1000 // regular operation: instructions change rarely (PO 2026-10-08)
26const TEST_REFRESH_MS = 2 * 60 * 1000 // test mode (/kinitro test)
27const PAGE_TEXT_TOOLS = ['mcp__remote-devices__Claude_Browser__get_page_text', 'mcp__Claude_Browser__get_page_text']
28const BROWSER_TOOLS = ['mcp__remote-devices__Claude_Browser__preview_start', 'mcp__Claude_Browser__preview_start'] // the Claude app's browser pane (cloud session linked to the computer / desktop session)
29const SNAPSHOT_FILE = '.kinitro-ai-selection.json' // approved slugs when the selection page was opened (selection-start)
30// Domain binding (kinitro.ai 2026-10-08): a connection may serve several domains; every call names its domain
31// with domainRef (the slug). The slug of this session lives in DOMAIN_FILE (set it with probe action 'set-domain').
32const DOMAIN_FILE = '.kinitro-ai-domain' // folder default: the last domain chosen in this working folder
33const SESSION_DOMAIN_PREFIX = '.kinitro-ai-domain.' // + session id: the domain of exactly this session (wins over the folder default)
34const NO_DOMAIN_ARG = new Set(['current-seat', 'list-domains']) // verbs that take no arguments
35// Each user message may start a fresh engine process while the kinitro.ai
36// connector is still connecting: the briefing and the hook state therefore
37// live in files that survive the restart, refreshed from kinitro.ai in the background.
38const CACHE_FILE = '.kinitro-ai-briefing.json'
39const STATE_FILE = '.kinitro-ai-state.json'
40const WAIT_FIRST_MS = 6000 // with no cache at all, wait this long for the connector
41
42// section names in the briefing
43const S = {
44 persona: 'Persona', rules: 'Working rules', start: 'Session start', turn: 'Every turn',
45 full: 'Context nearly full', after: 'After compaction', keep: 'Compaction instruction',
46} as const
47
48// ---- module state (resets on reload; session.start re-fills it) -----------
49type Briefing = { raw: string; sections: Record<string, string>; code: string; loadedAt: string; source: string; domainCode?: string; agentCode?: string; domainRef?: string; domainName?: string; agentName?: string; layers?: string }
50let briefing: Briefing | undefined
51let briefingError: string | undefined
52let lastLoadTry = 0
53let route = '-'
54let pendingPrompt = ''
55let warnedAt = 0
56let compactedPending = false
57let testArmed = false
58let lastInjected: string[] = []
59let composeCount = 0
60let lastMirrorError: string | undefined
61let compactions = 0
62let contextRenders = 0
63let personaSource = 'briefing document'
64let refreshing = false
65let refreshDone = false
66let domainRef: string | undefined // slug passed on every call; undefined = legacy seat-bound connection
67let bandRenders = 0
68let lastBandSurface = '-'
69let needsDomain: string | undefined // set when the connection serves several domains and no slug is configured
70const logLines: string[] = []
71
72// ---- band state (host-held, survives module reloads) ----------------------
73const approvedA = atom({ plugin: 'kinitro-ai', key: 'approved' } as const, [] as DomainChoice[])
74const currentA = atom({ plugin: 'kinitro-ai', key: 'current' } as const, null as string | null)
75const chooserA = atom({ plugin: 'kinitro-ai', key: 'chooser' } as const, false)
76const selectorUrlA = atom({ plugin: 'kinitro-ai', key: 'selectorUrl' } as const, null as string | null)
77const bandNoteA = atom({ plugin: 'kinitro-ai', key: 'bandNote' } as const, null as string | null)
78const testModeA = atom({ plugin: 'kinitro-ai', key: 'testMode' } as const, false)
79const startPackA = atom({ plugin: 'kinitro-ai', key: 'startPack' } as const, false) // inject the session-start package on the next prompt (after binding a domain)
80const contextKeyA = atom({ plugin: 'kinitro-ai', key: 'contextKey' } as const, '') // domain + hash of persona/rules the agent last received
81const boundA = atom({ plugin: 'kinitro-ai', key: 'bound' } as const, null as string | null) // this session's slug, held by the host: survives reloads and a changed working folder
82
83// ---- helpers: top level, because the engine only lets $ flow into these ----
84async function log($: any, line: string) {
85 logLines.push(`${new Date().toISOString()} ${line}`)
86 try { await $.fs.write(LOG_FILE, logLines.join('\n') + '\n') } catch { /* ignore */ }
87}
88
89// kinitro.ai call through the engine's tool path (same route as the model's own
90// mcp__kinitro_ai__* calls), falling back to the direct MCP route.
91// The kinitro.ai server name differs by host: `kinitro_ai` (cloud session config), `claude_ai_Kinitro`
92// (claude.ai connector inside Claude Code), or what the user named it. Found once from the session's tool list.
93let toolPrefix: string | undefined
94async function kinitroPrefix($: any): Promise<string> {
95 if (toolPrefix) return toolPrefix
96 try {
97 const names: string[] = (await $.tool.list()).map((t: any) => String(t.name))
98 const hits = names.filter((n) => /^mcp__.+__current-seat$/.test(n) && /kinitro|methos/i.test(n))
99 const pick = hits.find((n) => n === `mcp__${SERVER}__current-seat`) ?? hits[0]
100 if (pick) toolPrefix = pick.slice(0, -'current-seat'.length)
101 } catch { /* fall back to the default name */ }
102 return toolPrefix ?? `mcp__${SERVER}__`
103}
104
105async function mcp($: any, tool: string, args0: Record<string, unknown>) {
106 const args = domainRef && !NO_DOMAIN_ARG.has(tool) && args0.domainRef === undefined ? { ...args0, domainRef } : args0
107 let text: string
108 try {
109 const r: any = await $.tool.call({ tool: `${await kinitroPrefix($)}${tool}`, ...args })
110 if (r.deny) throw new Error(`denied: ${r.deny}`)
111 text = r.text ?? (typeof r.result === 'string' ? r.result : JSON.stringify(r.result))
112 if (r.isError) throw new Error(`${tool}: ${String(text).slice(0, 300)}`)
113 route = 'tool.call'
114 } catch (err1: any) {
115 try {
116 const server = (await kinitroPrefix($)).slice(5, -2)
117 const r = await $.mcp.call(server, tool, args)
118 text = (r.content ?? []).map((b: any) => (b.type === 'text' ? b.text : '')).join('')
119 if (r.isError) throw new Error(`${tool}: ${text.slice(0, 300)}`)
120 route = 'mcp.call'
121 } catch (err2: any) {
122 throw new Error(`tool.call: ${err1?.message ?? err1} | mcp.call: ${err2?.message ?? err2}`)
123 }
124 }
125 try { return JSON.parse(text) } catch { return { text } }
126}
127
128function parseSections(body: string): Record<string, string> {
129 const out: Record<string, string> = {}
130 for (const part of body.split(/^## /m).slice(1)) {
131 const nl = part.indexOf('\n')
132 out[part.slice(0, nl).trim()] = part.slice(nl + 1).trim()
133 }
134 return out
135}
136
137function sec(name: string): string {
138 return briefing?.sections[name] ?? ''
139}
140
141// identifies the persona and working rules the agent should hold: domain + content hash
142function personaKey(): string {
143 if (!briefing) return ''
144 const t = `${sec(S.persona)}\n${sec(S.rules)}`
145 let h = 5381
146 for (let i = 0; i < t.length; i++) h = ((h * 33) ^ t.charCodeAt(i)) >>> 0
147 return `${briefing.domainRef ?? '-'}:${t.length}:${h.toString(36)}`
148}
149
150
151// Layer A: the agent's own native instruction-* contents on its agent node, appended to the matching section
152const A_TYPES: Record<string, string> = {
153 [S.persona]: 'instruction-persona', [S.rules]: 'instruction-standing', [S.start]: 'instruction-coldstart',
154 [S.turn]: 'instruction-preturn-continue', [S.after]: 'instruction-postcompaction',
155}
156
157async function readFileTrim($: any, f: string): Promise<string | undefined> {
158 try { if (await $.fs.exists(f)) { const v = String(await $.fs.read(f)).trim(); return v || undefined } } catch { /* ignore */ }
159 return undefined
160}
161
162// the session's own choice first, then the folder default
163async function readDomainFile($: any): Promise<string | undefined> {
164 let sid = ''
165 try { sid = await $.session.id() } catch { /* ignore */ }
166 let held: string | null = null
167 try { held = await read($, boundA) } catch { /* ignore */ }
168 return held ?? (sid ? await readFileTrim($, SESSION_DOMAIN_PREFIX + sid) : undefined) ?? await readFileTrim($, DOMAIN_FILE)
169}
170
171// what the agent tells the user: names only, no codes, ids or paths
172function confirmation(slug: string, message: string) {
173 const ok = domainRef === slug && !!briefing
174 return ok
175 ? { bound: true, domain: briefing?.domainName ?? slug, agent: briefing?.agentName ?? null, domainView: message.split('domain view: ')[1] ?? null, sayToUser: `This session now works for ${briefing?.domainName ?? slug}${briefing?.agentName && briefing.agentName !== briefing.domainName ? ` with the agent ${briefing.agentName}` : ''}.` }
176 : { bound: false, reason: message }
177}
178
179async function domainFiles($: any): Promise<string[]> {
180 let sid = '', cwd = '.'
181 try { sid = await $.session.id() } catch { /* ignore */ }
182 try { cwd = await $.session.cwd() } catch { /* ignore */ }
183 return [sid ? `${cwd}/${SESSION_DOMAIN_PREFIX}${sid}` : '', `${cwd}/${DOMAIN_FILE}`].filter(Boolean)
184}
185
186async function writeDomainFiles($: any, slug: string) {
187 let sid = ''
188 try { sid = await $.session.id() } catch { /* ignore */ }
189 try { await update($, boundA, () => slug) } catch { /* ignore */ }
190 if (sid) await $.fs.write(SESSION_DOMAIN_PREFIX + sid, slug + '\n')
191 await $.fs.write(DOMAIN_FILE, slug + '\n')
192}
193
194// approved domains of the connection, and the address of the seat picker (approve more domains)
195async function refreshChoices($: any) {
196 try {
197 const seat = await mcp($, 'current-seat', {})
198 const approved: DomainChoice[] = (seat?.approved ?? []).map((d: any) => ({ slug: String(d.slug), name: String(d.name) }))
199 if (!approved.length && seat?.domain?.id) approved.push({ slug: '', name: String(seat.domain.name ?? 'seat-bound') })
200 await update($, approvedA, () => approved)
201 const anySlug = domainRef ?? approved.find((d) => d.slug)?.slug
202 if (anySlug) {
203 const r = await mcp($, 'open-page', { target: 'selector', domainRef: anySlug })
204 if (r?.url) await update($, selectorUrlA, () => String(r.url))
205 }
206 } catch (err: any) { await log($, `refreshChoices failed: ${err?.message ?? err}`) }
207}
208
209// what the user switched to on the selection page, read from the browser pane ("This connection may now work in <Domain> as <Agent>")
210async function switchedOnPage($: any, approved: DomainChoice[]): Promise<DomainChoice | undefined> {
211 for (const tool of PAGE_TEXT_TOOLS) {
212 try {
213 const r: any = await $.tool.call({ tool })
214 if (r?.deny || r?.isError) continue
215 const text = String(r?.text ?? (typeof r?.result === 'string' ? r.result : JSON.stringify(r?.result ?? '')))
216 const m = text.match(/may now work in (.+?) as /)
217 if (!m) return undefined
218 const hit = approved.find((d) => d.name === m[1]!.trim())
219 await log($, `page says switched to '${m[1]}' -> ${hit?.slug ?? 'no approved match'}`)
220 return hit
221 } catch { /* try the next tool */ }
222 }
223 return undefined
224}
225
226// open the domain view of the bound domain in the browser pane, straight from the plugin (no model turn)
227async function openDomainView($: any): Promise<string> {
228 if (!domainRef) return 'no domain bound'
229 let url: string | undefined
230 try { url = (await mcp($, 'open-page', {}))?.url } catch (err: any) { return `open-page failed: ${err?.message ?? err}` }
231 if (!url) return 'open-page gave no url'
232 return openInPane($, url)
233}
234
235async function openInPane($: any, url: string): Promise<string> {
236 const errs: string[] = []
237 for (const tool of BROWSER_TOOLS) {
238 try {
239 const r: any = await $.tool.call({ tool, url })
240 if (r?.deny) { errs.push(`${tool}: denied ${r.deny}`); continue }
241 if (r?.isError) { errs.push(`${tool}: ${String(r.text ?? r.result).slice(0, 160)}`); continue }
242 await log($, `pane opened via ${tool}`)
243 return `opened in the browser pane (${tool.split('__')[1]})`
244 } catch (err: any) { errs.push(`${tool}: ${String(err?.message ?? err).slice(0, 160)}`) }
245 }
246 await log($, `pane not opened: ${errs.join(' | ')}`)
247 return `browser pane not reachable; link: ${url}`
248}
249
250// bind this session to a domain: files, module state, briefing, band
251async function bindDomain($: any, slug: string): Promise<string> {
252 await writeDomainFiles($, slug)
253 briefing = undefined; needsDomain = undefined; domainRef = undefined
254 await writeState($)
255 await loadBriefing($)
256 const ok = briefing && domainRef === slug
257 await update($, currentA, () => (ok ? slug : null))
258 await update($, chooserA, () => !ok)
259 await update($, bandNoteA, () => (ok ? null : `could not bind ${slug}: ${briefingError ?? 'unknown'}`))
260 if (!ok) return `binding ${slug} failed: ${briefingError}`
261 await update($, startPackA, () => true)
262 try { $.ui.toast(`kinitro.ai: this session works for ${slug}`) } catch { /* ignore */ }
263 const view = await openDomainView($)
264 return `bound to ${slug}; domain view: ${view}`
265}
266
267async function loadBriefing($: any) {
268 lastLoadTry = Date.now()
269 try {
270 const seat = await mcp($, 'current-seat', {})
271 let domainId: string | undefined = seat?.domain?.id, agentId: string | undefined = seat?.agentNode?.id
272 if (domainId && agentId) domainRef = undefined // legacy: the connection is bound to one seat
273 else {
274 // multi-domain connection: the session names its domain by slug
275 const approved: any[] = seat?.approved ?? []
276 const slug = (await readDomainFile($)) ?? (approved.length === 1 ? approved[0].slug : undefined)
277 if (!slug) {
278 needsDomain = `this connection serves ${approved.length} domains (${approved.map((d: any) => d.slug).join(', ')}); set this session's domain with mcp__kinitro-ai__probe action "set-domain", domain "<slug>"`
279 try { await update($, approvedA, () => approved.map((x: any) => ({ slug: String(x.slug), name: String(x.name) }))); await update($, chooserA, () => true); await update($, currentA, () => null) } catch { /* ignore */ }
280 throw new Error(needsDomain)
281 }
282 const doms = await mcp($, 'list-domains', {})
283 const d = (doms?.domains ?? []).find((x: any) => x.slug === slug)
284 if (!d) throw new Error(`domain slug '${slug}' not found in list-domains`)
285 if (!d.approved) throw new Error(`domain '${slug}' is not approved for this connection`)
286 domainRef = slug; needsDomain = undefined
287 domainId = d.id; agentId = d.defaultAgentNodeId
288 if (!agentId) throw new Error(`domain '${slug}' has no default agent node`)
289 }
290 const [dom, agent] = await Promise.all([mcp($, 'get-node', { nodeId: domainId }), mcp($, 'get-node', { nodeId: agentId })])
291 const domainCode: string = dom?.code, agentCode: string = agent?.code
292 if (!domainCode || !agentCode) throw new Error('domain or agent node code not resolvable')
293 const sections: Record<string, string> = {}
294 const layers: string[] = []
295 let gCode: string | undefined
296 // Layers G + M: general briefing behind the domain's general-procedures edge
297 try {
298 const ed = await mcp($, 'list-edges', { nodeCode: domainCode, relation: GENERAL_EDGE, direction: 'outgoing', limit: 5 })
299 const target = ed?.edges?.[0]?.node?.code
300 if (target) {
301 const kids = await mcp($, 'list-nodes', { parentNodeCode: target, limit: 100 })
302 const doc = (kids?.nodes ?? []).find((n: any) => String(n.name).startsWith(GENERAL_BRIEFING_PREFIX))
303 if (doc) {
304 const g = await mcp($, 'load-node-content', { nodeCode: doc.code, contentTypeCode: 'md' })
305 if (g?.body && g?.contentTypeCode === 'md') { Object.assign(sections, parseSections(g.body)); gCode = doc.code; layers.push(`G+M ${doc.code}`) }
306 }
307 }
308 } catch (err: any) { await log($, `general briefing failed: ${err?.message ?? err}`) }
309 // Layer A: agent node
310 const aRes = await Promise.all(Object.entries(A_TYPES).map(async ([title, type]) => {
311 // the verb answers with ANOTHER row when the asked type is missing (platform G7): take the body only when the type matches
312 try { const r = await mcp($, 'load-node-content', { nodeCode: agentCode, contentTypeCode: type }); return [title, (r?.contentTypeCode === type ? r?.body : undefined) as string | undefined] as const }
313 catch { return [title, undefined] as const }
314 }))
315 for (const [title, body] of aRes) {
316 if (!body) continue
317 if (title === S.persona || !sections[title]) sections[title] = body
318 else sections[title] = `${sections[title]}\n\n**Agent-specific:**\n${body}`
319 layers.push(`A ${A_TYPES[title]}`)
320 }
321 personaSource = sections[S.persona] ? `${agentCode} instruction-persona` : 'none'
322 if (!Object.keys(sections).length) throw new Error(`no briefing found: no '${GENERAL_EDGE}' edge with a '${GENERAL_BRIEFING_PREFIX}' document on ${domainCode}, and no instruction-* on ${agentCode}`)
323 const raw = Object.entries(sections).map(([k, v]) => `## ${k}\n${v}`).join('\n\n')
324 briefing = { raw, sections, code: gCode ?? agentCode, loadedAt: new Date().toISOString(), source: 'kinitro.ai', domainCode, agentCode, domainRef, domainName: dom?.name, agentName: agent?.name, layers: layers.join(', ') }
325 briefingError = undefined
326 try { await update($, currentA, () => domainRef ?? '') } catch { /* ignore */ }
327 try { await $.fs.write(CACHE_FILE, JSON.stringify(briefing)) } catch { /* ignore */ }
328 await log($, `briefing loaded for domain ${domainCode} / agent ${agentCode} via ${route}: ${raw.length} chars, layers ${briefing.layers}`)
329 } catch (err: any) {
330 briefingError = String(err?.message ?? err)
331 await log($, `briefing FAILED: ${briefingError}`)
332 }
333}
334
335// the connector may not be up yet at session start: retry lazily
336async function ensureBriefing($: any) {
337 if (!briefing && !needsDomain && Date.now() - lastLoadTry > RETRY_MS) await loadBriefing($)
338}
339
340async function loadCache($: any) {
341 try {
342 if (!(await $.fs.exists(CACHE_FILE))) return
343 const c = JSON.parse(await $.fs.read(CACHE_FILE))
344 if (c?.raw) {
345 const want = await readDomainFile($)
346 if (want && c.domainRef && want !== c.domainRef) { await log($, `cache is for ${c.domainRef}, session domain is ${want}: ignored`); return }
347 briefing = { ...c, sections: c.sections ?? parseSections(c.raw), source: 'cache' }
348 if (c.domainRef) domainRef = c.domainRef
349 await log($, `briefing from cache (${c.code}, domain ${c.domainRef ?? 'seat-bound'}, loaded ${c.loadedAt})`)
350 }
351 } catch (err: any) { await log($, `cache read failed: ${err?.message ?? err}`) }
352}
353
354async function readState($: any) {
355 try {
356 if (!(await $.fs.exists(STATE_FILE))) return
357 const st = JSON.parse(await $.fs.read(STATE_FILE))
358 testArmed = !!st.testArmed; warnedAt = st.warnedAt ?? 0; compactedPending = !!st.compactedPending
359 compactions = st.compactions ?? 0; contextRenders = st.contextRenders ?? 0
360 } catch (err: any) { await log($, `state read failed: ${err?.message ?? err}`) }
361}
362
363async function writeState($: any) {
364 try {
365 await $.fs.write(STATE_FILE, JSON.stringify({ testArmed, warnedAt, compactedPending, compactions, contextRenders }))
366 } catch { /* ignore */ }
367}
368
369// refresh from kinitro.ai once the connector is up (session.start timer)
370async function refreshTick($: any) {
371 if (briefing?.source === 'kinitro.ai') return true
372 const cached = briefing
373 await loadBriefing($)
374 if (!briefing && cached) briefing = cached
375 return briefing?.source === 'kinitro.ai'
376}
377
378// first message of a brand-new container: nothing cached, wait for the connector
379async function waitForBriefing($: any) {
380 const until = Date.now() + WAIT_FIRST_MS
381 while (!briefing && !needsDomain && Date.now() < until) {
382 await loadBriefing($)
383 if (!briefing) await $.clock.sleep(1000)
384 }
385}
386
387// ---- chat mirror (item 3413) ------------------------------------------------
388type OutEntry = { sessionId: string; turnId: string; prompt: string; answer: string; eventTs: string; domainRef?: string; sent?: boolean; result?: string }
389type InMsg = { at?: string; from?: string; text: string; raw?: unknown }
390let mirrorSent = 0, mirrorDup = 0, lastMirrorTarget = '-', inboxNew = 0, lastFetch = '-'
391let flushing = false, fetching = false
392
393async function readJson<T>($: any, f: string, empty: T): Promise<T> {
394 try { if (await $.fs.exists(f)) return JSON.parse(await $.fs.read(f)) as T } catch { /* ignore */ }
395 return empty
396}
397
398function cut(t: string): string { return t.length > MAX_TEXT ? t.slice(0, MAX_TEXT) + ' [...truncated]' : t }
399
400// send every unsent outbox entry; keep at most BOX_MAX entries (oldest sent ones go first)
401async function flushOutbox($: any) {
402 if (flushing) return
403 flushing = true
404 try {
405 const box = await readJson<OutEntry[]>($, OUTBOX_FILE, [])
406 let changed = false
407 for (const o of box) {
408 if (o.sent) continue
409 const ref = o.domainRef ?? domainRef
410 if (!ref && needsDomain) break // no domain yet: send after binding
411 try {
412 const r = await mcp($, 'mirror-messaging', { action: 'append', platform: MIRROR_PLATFORM, sessionId: o.sessionId, turnId: o.turnId, prompt: o.prompt, answer: o.answer, eventTs: o.eventTs, ...(ref ? { domainRef: ref } : {}) })
413 if (r?.status && r.status !== 'ok') throw new Error(JSON.stringify(r).slice(0, 200))
414 o.sent = true; o.domainRef = ref; changed = true
415 o.result = r?.alreadyPresent ? 'alreadyPresent' : `rows ${(r?.rowIds ?? []).length}`
416 if (r?.alreadyPresent) mirrorDup++; else mirrorSent++
417 lastMirrorTarget = r?.target?.agentNodeId ?? lastMirrorTarget
418 lastMirrorError = undefined
419 } catch (err: any) { lastMirrorError = String(err?.message ?? err).slice(0, 300); await log($, `mirror append failed: ${lastMirrorError}`); break }
420 }
421 if (box.length > BOX_MAX) { const keep = box.filter((o) => !o.sent).concat(box.filter((o) => o.sent).slice(-BOX_MAX)); box.splice(0, box.length, ...keep.slice(-BOX_MAX)); changed = true }
422 if (changed) await $.fs.write(OUTBOX_FILE, JSON.stringify(box))
423 } finally { flushing = false }
424}
425
426async function queueTurn($: any, turnId: string, prompt: string, answer: string) {
427 let sid = ''
428 try { sid = await $.session.id() } catch { /* ignore */ }
429 const box = await readJson<OutEntry[]>($, OUTBOX_FILE, [])
430 if (!box.some((o) => o.sessionId === sid && o.turnId === turnId)) box.push({ sessionId: sid, turnId, prompt: cut(prompt), answer: cut(answer), eventTs: new Date().toISOString(), domainRef })
431 await $.fs.write(OUTBOX_FILE, JSON.stringify(box.slice(-BOX_MAX)))
432 await flushOutbox($)
433}
434
435// new chat messages since the stored cursor (a first fetch without cursor starts from now)
436async function fetchInbox($: any) {
437 if (fetching || !domainRef) return
438 fetching = true
439 try {
440 let sid = ''
441 try { sid = await $.session.id() } catch { /* ignore */ }
442 const inbox = await readJson<{ cursor?: string; messages: (InMsg & { told?: boolean })[] }>($, INBOX_FILE, { messages: [] })
443 let more = true, rounds = 0
444 while (more && rounds++ < 5) {
445 const r = await mcp($, 'mirror-messaging', { action: 'fetch', platform: MIRROR_PLATFORM, sessionId: sid, ...(inbox.cursor ? { cursor: inbox.cursor } : {}), limit: 50 })
446 if (r?.status && r.status !== 'ok') throw new Error(JSON.stringify(r).slice(0, 200))
447 for (const m of (r?.messages ?? []) as any[]) {
448 if (m?.sessionId === sid || m?.platform === MIRROR_PLATFORM && m?.sessionId === sid) continue // our own mirrored turns
449 const text = String(m?.text ?? m?.body ?? m?.content ?? '').trim()
450 if (!text) continue
451 inbox.messages.push({ at: m?.at ?? m?.createdAt ?? m?.timestamp, from: m?.from ?? m?.sender ?? m?.senderType ?? m?.role, text, raw: undefined })
452 inboxNew++
453 }
454 if (r?.nextCursor) inbox.cursor = r.nextCursor
455 more = !!r?.more
456 }
457 inbox.messages = inbox.messages.slice(-BOX_MAX)
458 await $.fs.write(INBOX_FILE, JSON.stringify(inbox))
459 lastFetch = new Date().toISOString()
460 } catch (err: any) { await log($, `mirror fetch failed: ${String(err?.message ?? err).slice(0, 300)}`) }
461 finally { fetching = false }
462}
463
464// messages not yet told to the agent; marks them as told
465async function takeInbox($: any): Promise<InMsg[]> {
466 const inbox = await readJson<{ cursor?: string; messages: (InMsg & { told?: boolean })[] }>($, INBOX_FILE, { messages: [] })
467 const fresh = inbox.messages.filter((m) => !m.told)
468 if (!fresh.length) return []
469 for (const m of fresh) m.told = true
470 await $.fs.write(INBOX_FILE, JSON.stringify(inbox))
471 return fresh
472}
473
474function briefingBlock(): string {
475 if (!briefing) return ''
476 return `kinitro.ai briefing (from ${briefing.code}, applies to the whole session)\n\n## ${S.persona}\n${sec(S.persona)}\n\n## ${S.rules}\n${sec(S.rules)}`
477}
478
479async function contextPercent($: any): Promise<number | undefined> {
480 try { return (await $.session.usage()).context.percent } catch { return undefined }
481}
482
483async function statusText($: any): Promise<string> {
484 const pct = await contextPercent($)
485 return [
486 `kinitro-ai ${VERSION}`,
487 `briefing: ${briefing ? `domain ${briefing.domainCode ?? '?'} / agent ${briefing.agentCode ?? '?'} (${briefing.raw.length} chars, source ${briefing.source}, loaded ${briefing.loadedAt}, route ${route})` : `MISSING (${briefingError})`}`,
488 `domain: ${domainRef ?? (needsDomain ? 'NOT SET - ' + needsDomain : 'seat-bound connection')}`,
489 `kinitro.ai tools: ${await kinitroPrefix($)}*`,
490 `refresh: ${(await read($, testModeA)) ? 'test mode, every 2 min' : 'daily'} · instructions loaded ${briefing?.loadedAt ?? '-'}`,
491 `layers: ${briefing?.layers ?? '-'}`,
492 `sections: ${briefing ? Object.keys(briefing.sections).join(' | ') : '-'} · persona from ${personaSource}`,
493 `system prompt section injected in ${composeCount} renders (prompt.compose) · first-message context block rendered ${contextRenders}x (prompt.context)`,
494 `context: ${pct ?? '?'} % (warn at ${WARN_PCT} %, last warned ${warnedAt || '-'})`,
495 `last prompt injected: ${lastInjected.join(', ') || '-'}`,
496 `test armed: ${testArmed} · compactions seen: ${compactions} · post-compact pending: ${compactedPending}`,
497 `chat mirror: ${mirrorSent} turns sent, ${mirrorDup} already present, agent ${lastMirrorTarget}${lastMirrorError ? ' · last error: ' + lastMirrorError : ''} · inbox ${inboxNew} new since start, last fetch ${lastFetch}`,
498 ].join('\n')
499}
500
501
502// other Claude sessions of this agent today (from the protocol documents): memory is shared by design, so the agent is warned
503async function otherSessionsToday($: any): Promise<string[]> {
504 try {
505 let sid = ''
506 try { sid = String(await $.session.id()).slice(0, 8) } catch { /* ignore */ }
507 const today = new Date().toISOString().slice(0, 10)
508 const r = await mcp($, 'find-nodes', { name: `Protocol: Claude session ${today}`, limit: 25 })
509 return (r?.matches ?? []).map((m: any) => String(m.name).match(/\(([0-9a-f]{8})\)$/)?.[1]).filter((x: any) => x && x !== sid)
510 } catch (err: any) { await log($, `other sessions check failed: ${err?.message ?? err}`); return [] }
511}
512
513// ---- working memory of the seat (PO 2026-10-06): Todo every turn; Notes + topic map at start and after compaction
514function cleanTodo(body: string): string | undefined {
515 // drop the cache marker and empty sections; nothing to show when there are no numbered todos, exceptions or questions
516 const lines = body.split('\n').filter((l) => !l.includes('{{mx:cache-cut}}'))
517 const out: string[] = []
518 for (let i = 0; i < lines.length; i++) {
519 const l = lines[i] ?? ''
520 if (/^## /.test(l)) {
521 const next = lines.slice(i + 1).find((x) => x.trim() !== '')
522 if (!next || /^## /.test(next)) continue
523 out.push(l.replace('## Instructions', '## Todo (user-approved)'))
524 } else if (l.trim()) out.push(l)
525 }
526 return out.some((l) => !/^## /.test(l)) ? out.join('\n') : undefined
527}
528
529async function readSet($: any, type: string): Promise<string | undefined> {
530 if (!briefing?.agentCode) return undefined
531 try {
532 const r = await mcp($, 'load-node-content', { nodeCode: briefing.agentCode, contentTypeCode: type })
533 return r?.contentTypeCode === type ? (r.body as string | undefined) : undefined // the verb may answer with another row when the set does not exist
534 } catch (err: any) { await log($, `read ${type} failed: ${err?.message ?? err}`); return undefined }
535}
536
537async function topicMap($: any): Promise<string | undefined> {
538 try {
539 const r = await mcp($, 'search-memory', { listTopics: true })
540 const t = (r?.topics ?? []).slice(0, 40).map((x: any) => `- ${x.topic} (${x.nodeCode}, ${x.entryCount} entries, newest ${String(x.newestUtc ?? '').slice(0, 10)})`)
541 return t.length ? t.join('\n') : undefined
542 } catch (err: any) { await log($, `topic map failed: ${err?.message ?? err}`); return undefined }
543}
544
545// ---- hooks -----------------------------------------------------------------
546export const register: Register = (on) => {
547 on('session.start', async ($, e, next) => {
548 await log($, `session.start ${VERSION} (interactive=${e.isInteractive}, surface=${e.surface})`)
549 await readState($)
550 await loadCache($)
551 $.clock.every(FETCH_MS, () => { flushOutbox($).then(() => fetchInbox($)) })
552 try { await log($, `surfaces: ${JSON.stringify(await $.session.surfaces())}`) } catch (err: any) { await log($, `surfaces failed: ${err?.message ?? err}`) }
553 refreshChoices($)
554 let tries = 0
555 const timer = $.clock.every(3000, () => {
556 if (refreshing || refreshDone) return
557 refreshing = true
558 tries++
559 refreshTick($).then((done) => {
560 refreshing = false
561 if (done || tries > 40 || needsDomain) { refreshDone = true; timer.cancel(); log($, `refresh ${done ? 'done' : 'gave up'} after ${tries} tries`) }
562 })
563 })
564 // /kinitro is a static command file (commands/kinitro.md): the Claude app lists only those
565 try {
566 await $.tool.register({
567 name: 'probe',
568 description: 'kinitro-ai harness control. action "status" (default): diagnostics; "test-mode" with value on|off: refresh the instructions every 2 minutes instead of daily; "selection-start": remember the approved domains and return the selection page address (call before the user chooses on that page); "selection-done": compare with that list and bind the newly approved domain, or return the list to ask from; "set-domain" with domain "<slug>": bind this session to a kinitro.ai domain (slug from list-domains) and reload; "reload": re-read the briefing from kinitro.ai; "arm-test": inject EVERY briefing section, conditional ones included, into the next user prompt, marked [TEST]; "invalidate-context": re-render the first-message context block (persona) on the next request.',
569 inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['status', 'test-mode', 'choices', 'open-domain', 'selection-start', 'selection-done', 'set-domain', 'reload', 'arm-test', 'invalidate-context'] }, domain: { type: 'string', description: 'domain slug for set-domain, e.g. verum' }, value: { type: 'string', enum: ['on', 'off'], description: 'for test-mode' } } },
570 })
571 } catch (err: any) { await log($, `tool.register failed: ${err?.message ?? err}`) }
572 return next(e)
573 })
574
575 on('tool.call', { tool: 'mcp__kinitro-ai__probe' }, async ($, e: any) => {
576 const action = e.action ?? 'status'
577 if (action === 'open-domain') return { result: await openDomainView($) }
578 if (action === 'choices') {
579 const seat = await mcp($, 'current-seat', {})
580 const approved: DomainChoice[] = (seat?.approved ?? []).map((d: any) => ({ slug: String(d.slug), name: String(d.name) }))
581 await update($, approvedA, () => approved)
582 return { result: JSON.stringify({ approved, current: domainRef ? (briefing?.domainName ?? domainRef) : null }) }
583 }
584 if (action === 'selection-start') {
585 const seat = await mcp($, 'current-seat', {})
586 const approved: DomainChoice[] = (seat?.approved ?? []).map((d: any) => ({ slug: String(d.slug), name: String(d.name) }))
587 const anySlug = domainRef ?? approved[0]?.slug
588 const page = anySlug ? await mcp($, 'open-page', { target: 'selector', domainRef: anySlug }) : undefined
589 await $.fs.write(SNAPSHOT_FILE, JSON.stringify({ at: new Date().toISOString(), approved }))
590 await update($, approvedA, () => approved)
591 if (page?.url) await update($, selectorUrlA, () => String(page.url))
592 const opened = page?.url ? await openInPane($, String(page.url)) : 'no selection page address'
593 await log($, `selection-start: ${approved.map((d) => d.slug).join(',')}; ${opened}`)
594 return { result: JSON.stringify({ selectionPage: opened, selectorUrl: opened.startsWith('opened') ? undefined : page?.url ?? null }) }
595 }
596 if (action === 'selection-done') {
597 let before: DomainChoice[] = []
598 try { before = JSON.parse(await $.fs.read(SNAPSHOT_FILE)).approved ?? [] } catch { /* no snapshot */ }
599 const seat = await mcp($, 'current-seat', {})
600 const approved: DomainChoice[] = (seat?.approved ?? []).map((d: any) => ({ slug: String(d.slug), name: String(d.name) }))
601 await update($, approvedA, () => approved)
602 const fresh = approved.filter((d) => !before.some((b) => b.slug === d.slug))
603 await log($, `selection-done: new ${fresh.map((d) => d.slug).join(',') || '-'}`)
604 const pick = fresh.length === 1 ? fresh[0] : await switchedOnPage($, approved)
605 if (pick) {
606 const r = await bindDomain($, pick.slug)
607 return { result: JSON.stringify(confirmation(pick.slug, r)) }
608 }
609 return { result: JSON.stringify({ bound: null, newlyApproved: fresh, approved, message: fresh.length ? 'several domains were approved: ask which one' : 'no newly approved domain: the user chose one that was already approved; ask which one' }) }
610 }
611 if (action === 'set-domain') {
612 const slug = String(e.domain ?? '').trim()
613 if (!slug) return { result: 'set-domain needs domain: "<slug>" (see list-domains)' }
614 const r = await bindDomain($, slug)
615 return { result: JSON.stringify(confirmation(slug, r)) }
616 }
617 if (action === 'reload') { needsDomain = undefined; await loadBriefing($) }
618 if (action === 'test-mode') { const enable = String(e.value ?? 'on') !== 'off'; await update($, testModeA, () => enable); await log($, `test mode ${enable ? 'on' : 'off'}`) }
619 if (action === 'arm-test') { testArmed = true; await writeState($) }
620 if (action === 'invalidate-context') $.ui.invalidate('prompt.context')
621 const res = `action: ${action}\n${await statusText($)}`
622 await log($, `probe ${action}`)
623 return { result: res }
624 })
625
626 // FIRST-MESSAGE CONTEXT BLOCK: Persona + Working rules. Rendered once per
627 // conversation and again after every compaction (the host owns the system prompt here).
628 on('prompt.context', async ($, e, next) => {
629 const r = await next(e)
630 if (!briefing) await loadCache($)
631 const text = briefingBlock()
632 if (!text) return r
633 contextRenders++
634 await update($, contextKeyA, () => personaKey())
635 await writeState($)
636 await log($, `prompt.context: kinitroBriefing block added (${text.length} chars), render #${contextRenders}`)
637 return { ...r, blocks: [...r.blocks.filter((b: any) => b.name !== 'kinitroBriefing'), { name: 'kinitroBriefing', text }] }
638 })
639
640 // SYSTEM PROMPT (every request): Persona + Working rules -- does not fire in the claude.ai cloud host
641 on('prompt.compose', async ($, e, next) => {
642 const r = await next(e)
643 await ensureBriefing($)
644 if (!briefing) return r
645 composeCount++
646 const text = `# kinitro.ai briefing (from ${briefing.code})\n\n## ${S.persona}\n${sec(S.persona)}\n\n## ${S.rules}\n${sec(S.rules)}`
647 return { sections: [...r.sections, { id: 'kinitro-briefing', text, scope: 'session' as const }] }
648 })
649
650 // EVERY USER PROMPT: marker, start (first prompt), per-turn, 70 %, post-compaction
651 on('prompt.submit', async ($, e, next) => {
652 if (!briefing) await waitForBriefing($)
653 const turns0 = await $.session.turns().catch(() => -1)
654 // freshness: a new session always starts from kinitro.ai, later the copy is renewed daily (test mode: every 2 minutes)
655 // the /kinitro fallback bound the domain while the plugin was still starting: finish it now (start package, domain view)
656 try {
657 if (await $.fs.exists(PENDING_VIEW_FILE)) {
658 const slug = String(await $.fs.read(PENDING_VIEW_FILE)).trim()
659 await $.fs.write(PENDING_VIEW_FILE, '')
660 if (slug) { await bindDomain($, slug); await log($, `pending binding finished for ${slug}`) }
661 }
662 } catch (err: any) { await log($, `pending binding failed: ${err?.message ?? err}`) }
663 if (briefing && !needsDomain) {
664 const age = Date.now() - Date.parse(briefing.loadedAt ?? '1970-01-01')
665 const limit = (await read($, testModeA)) ? TEST_REFRESH_MS : REFRESH_MS
666 // a local copy (plugin restarted while the connector was still connecting) is replaced at the next prompt, not only after a day
667 if (briefing.source !== 'kinitro.ai' || age > limit) { const kept = briefing; await loadBriefing($); if (!briefing) briefing = kept }
668 }
669 pendingPrompt = e.text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>\s*/g, '').trim()
670 const pct = await contextPercent($)
671 const turns = await $.session.turns().catch(() => -1)
672 const tag = testArmed ? '[TEST] ' : ''
673 const blocks: string[] = [`[kinitro-ai ${VERSION}] context ${pct ?? '?'} % · briefing ${briefing ? briefing.code : 'MISSING (' + briefingError + ')'}`]
674 const injected: string[] = ['marker']
675 if (needsDomain) {
676 const approved = await read($, approvedA)
677 const url = await read($, selectorUrlA)
678 blocks.push([
679 'kinitro.ai domain not set for this session. Before any other work:',
680 `1. Ask the user which domain this session works for, with AskUserQuestion (one option per approved domain: ${approved.map((d) => `${d.name} = ${d.slug}`).join('; ') || 'none listed'}).`,
681 url ? `2. If their domain is missing, give them this link to approve it, then ask again: ${url}` : '2. If their domain is missing, ask them to approve it for this connection in kinitro.ai.',
682 '3. Bind the session: call mcp__kinitro-ai__probe with action "set-domain" and domain "<slug>". Confirm the domain in one line.',
683 `Detail: ${needsDomain}.`,
684 ].join('\n'))
685 injected.push('Domain missing')
686 }
687 if (!needsDomain && domainRef) {
688 await flushOutbox($)
689 await fetchInbox($)
690 const msgs = await takeInbox($)
691 if (msgs.length) { blocks.push(`New messages in your kinitro.ai chat (${msgs.length}), sent there since your last turn - read them and answer in your reply where they concern the work:\n${msgs.map((m) => `- ${m.at ? String(m.at).slice(0, 16).replace('T', ' ') + ' ' : ''}${m.from ? '(' + m.from + ') ' : ''}${m.text.slice(0, 2000)}`).join('\n')}`); injected.push('Chat inbox') }
692 }
693 if (needsDomain) { /* domain block above */ }
694 else if (domainRef) blocks[0] += ` · domain ${domainRef} (pass domainRef:'${domainRef}' on every ${await kinitroPrefix($)}* call)`
695 const add = (key: string, title: string, why: string) => {
696 const t = sec(title)
697 if (!t) return
698 blocks.push(`${tag}${title}${why ? ' (' + why + ')' : ''}:\n${t}`)
699 injected.push(title)
700 }
701 const startPack = !needsDomain && (await read($, startPackA))
702 const startOrAfter = turns === 0 || compactedPending || testArmed || startPack
703 if (briefing) {
704 // persona and working rules: when the agent does not hold the current ones (domain switch, refresh with changes, stale start block)
705 // after choosing a domain always: a re-render request does not reach a running conversation (measured 2026-10-08)
706 if (!needsDomain && (startPack || (await read($, contextKeyA)) !== personaKey())) {
707 add('persona', S.persona, startPack ? `domain ${briefing.domainName ?? domainRef}` : 'updated')
708 add('rules', S.rules, '')
709 await update($, contextKeyA, () => personaKey())
710 }
711 if (turns === 0 || testArmed || startPack) add('start', S.start, testArmed ? 'Test' : startPack ? 'domain chosen' : 'first turn')
712 if (startPack || turns === 0) {
713 const others = needsDomain ? [] : await otherSessionsToday($)
714 if (others.length) { blocks.push(`Shared agent: ${others.length} other Claude session(s) worked with this agent today (${others.join(', ')}). Todos, notes and memory are shared with them. Before acting on a todo you did not start in this session, check its exception line and the notes; tell the user once that another session is active on this agent.`); injected.push('Other sessions') }
715 }
716 if (startPack) await update($, startPackA, () => false)
717 add('turn', S.turn, '')
718 if (compactedPending || testArmed) { add('after', S.after, testArmed ? 'Test' : 'after compaction'); compactedPending = false }
719 const full = pct !== undefined && pct >= WARN_PCT && pct >= warnedAt + REWARN_STEP
720 if (full || testArmed) {
721 add('full', S.full, testArmed ? `Test, context ${pct} %` : `context ${pct} %, compaction at about 80 %`)
722 if (full) warnedAt = pct as number
723 }
724 if (testArmed) {
725 blocks.push(`[TEST] Compaction instruction (otherwise goes ONLY to the summarizer, not to you):\n${sec(S.keep)}`)
726 injected.push(S.keep)
727 blocks.push('[TEST] Please confirm for each block whether it arrived, and whether the context holds the kinitro.ai briefing with persona and working rules. The blocks are test content, not tasks.')
728 testArmed = false
729 }
730 // working memory: Todo every turn (fresh read, no model tokens for the read itself)
731 const todo = await readSet($, 'memory-instruction')
732 const todoText = todo ? cleanTodo(todo) : undefined
733 if (todoText) { blocks.push(`${tag}Your todos (from ${briefing.agentCode}; keep them current with memory-append / memory-instruction-status):\n${todoText}`); injected.push('Todo') }
734 if (startOrAfter) {
735 const notes = await readSet($, 'memory-notes')
736 if (notes && notes.split('\n').some((l) => l.startsWith('- '))) { blocks.push(`${tag}Your notes (scratchboard; migrate durable facts to topics at the checkpoint):\n${notes.trim()}`); injected.push('Notes') }
737 const map = await topicMap($)
738 if (map) { blocks.push(`${tag}Your memory topics (map; read a topic with search-memory or load-node-content when you need it):\n${map}`); injected.push('Topic map') }
739 }
740 }
741 lastInjected = injected
742 await writeState($)
743 await log($, `prompt.submit turns=${turns} pct=${pct} injected=${injected.join(',')}`)
744 return next({ ...e, context: [...(e.context ?? []), blocks.join('\n\n')] })
745 })
746
747 // COMPACTION: steer the summarizer, then flag the post-compaction block
748 on('session.compact', async ($, e, next) => {
749 if (e.agentId) return next(e)
750 await log($, `session.compact trigger=${e.trigger} messages=${e.messages.length}`)
751 const keep = sec(S.keep)
752 const r: any = await next(keep ? { ...e, instructions: (e.instructions ? e.instructions + '\n\n' : '') + keep } : e)
753 if (!r.skip && e.trigger !== 'precompute') {
754 compactions++
755 compactedPending = true
756 warnedAt = 0
757 await writeState($)
758 }
759 await log($, `session.compact done skip=${r.skip ?? '-'} before=${r.tokensBefore} after=${r.tokensAfter}`)
760 return r
761 })
762
763 // END OF TURN: prompt and final answer into the agent's kinitro.ai chat (item 3413); outbox first, then send
764 on('turn.complete', async ($, e, next) => {
765 const r = await next(e)
766 if (e.agentId) return r
767 const prompt = pendingPrompt
768 pendingPrompt = ''
769 if (prompt || e.answer) await queueTurn($, e.turnId, prompt, e.answer ?? '')
770 await log($, `turn.complete reason=${e.reason} mirrorSent=${mirrorSent} dup=${mirrorDup}${lastMirrorError ? ' error=' + lastMirrorError : ''}`)
771 return r
772 })
773
774 // DOMAIN BAND above the prompt: which kinitro.ai domain this session works for; choose or change it
775 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
776 if ((e as any).props?.hasSurvey) return next(e)
777 const { Box, Text, Button, Link } = $.ui.resolve(e) as any
778 bandRenders++; lastBandSurface = String((e as any).surface)
779 const approved = await read($, approvedA)
780 const current = await read($, currentA)
781 const chooser = await read($, chooserA)
782 const url = await read($, selectorUrlA)
783 const note = await read($, bandNoteA)
784 const name = (slug: string | null) => approved.find((d) => d.slug === slug)?.name ?? slug
785 if (!chooser && current) {
786 return (
787 <Box>
788 <Text dimColor>kinitro.ai · {name(current)} ({current}) </Text>
789 <Button key="kin-change" label="Change domain" dimColor onPress={() => { update($, chooserA, () => true); refreshChoices($) }} />
790 </Box>
791 )
792 }
793 return (
794 <Box flexDirection="column">
795 <Text>{current ? `kinitro.ai · this session works for ${name(current)}. Switch to:` : 'kinitro.ai · choose the domain for this session:'}</Text>
796 <Box>
797 {approved.filter((d) => d.slug && d.slug !== current).map((d) => (
798 <Button key={'kin-' + d.slug} label={d.name} variant={approved.length === 1 ? 'primary' : undefined} onPress={() => { bindDomain($, d.slug) }} />
799 ))}
800 <Button key="kin-refresh" label="Refresh list" dimColor onPress={() => { refreshChoices($) }} />
801 {current ? <Button key="kin-close" label="Close" role="dismiss" onPress={() => { update($, chooserA, () => false) }} /> : null}
802 </Box>
803 {url ? <Text dimColor>Domain missing? <Link href={url} label="Open the kinitro.ai domain selection" />, approve it there, then Refresh list.</Text> : null}
804 {note ? <Text dimColor>{note}</Text> : null}
805 </Box>
806 )
807 })
808}
809types/index.d.ts 18 lines1export type DomainChoice = { slug: string; name: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'kinitro-ai': {
6 approved: DomainChoice[]
7 current: string | null
8 chooser: boolean
9 selectorUrl: string | null
10 bandNote: string | null
11 bound: string | null
12 testMode: boolean
13 startPack: boolean
14 contextKey: string
15 }
16 }
17}
18