SLOPSHOPPER

kinitro-ai

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

newbandguardtoastprompttool
v0.19.0NOASSERTIONupdated 2026-10-08ThorstenHornung/kinitro.ai/plugins/kinitro-ai
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kinitro-ai
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM kinitro.ai · choose the domain for this session: [ Refresh list ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
kinitro.ai · choose the domain for this session: [ Refresh list ]
README

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.

What kinitro.ai is

  • A platform for rich agentic solutions. Memory, skills, domain knowledge, data, procedures and user interface live together in one governed place.
  • Built for business users. You work with an agent in plain language. You do not need platform or technical knowledge.
  • Open to partners. Partners can offer specialized agent solutions on kinitro.ai, at a charge. Verum ("agent-led business planning & intelligence") is one example.
  • Works with several agentic platforms. It starts with Claude (Anthropic) and is being extended to other platforms rapidly.
  • Governed. Instructions, knowledge and results sit in your own kinitro.ai domain, where the domain owner controls them.

What is in this repository

PathWhat 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.jsonThe marketplace entry that lets Claude Code install the plugin.
docs/Guides for users, setup people and partners.
assets/The kinitro ai logo.

Quick start

You need three things:

  1. A kinitro.ai account with an agent seat (a domain and an agent node, set up by kinitro or a partner).
  2. The kinitro.ai connector connected in Claude under the server name kinitro_ai.
  3. Claude Code with plugin support (terminal, or the Code tab of the desktop app), and 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.

How the plugin briefs your agent

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.

MomentWhat is injected
First message of a conversation, and again after each compactionPersona and Working rules
First prompt of a sessionSession start
Every promptA one-line marker, the Every turn section and the agent's current todo list
First prompt and after a compactionThe 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 compactionAfter compaction
While Claude compacts the conversationCompaction instruction (goes to the summarizer)
End of every turnYour prompt and the agent's final answer are written into the agent's chat in kinitro.ai

Details: How it works and Plugin reference.

For partners

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.

Documentation

License

Proprietary beta software. See LICENSE.


Source 2 files
hooks/register.tsx 809 lines
1import { 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}
809
types/index.d.ts 18 lines
1export 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