poi-plugin-mcp 0.2.25 → 0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ const path = require('path')
1
2
  const { createPoiDataBridge } = require('./poi-http-bridge')
2
3
  const { loadOrCreateInputToken } = require('./input-token')
3
4
  const {
@@ -10,13 +11,14 @@ const {
10
11
  function createBridgeController(options = {}) {
11
12
  const settingsPath = options.settingsPath || DEFAULT_SETTINGS_FILE
12
13
  const logger = options.logger || console
13
- const createBridge = options.createBridge || createPoiDataBridge
14
+ let createBridge = options.createBridge || createPoiDataBridge
14
15
 
15
16
  let settings = loadSettings(settingsPath)
16
17
  let bridge = null
17
18
  let recorder = null
18
19
  let inputToken = options.inputToken || null
19
20
  let pending = Promise.resolve()
21
+ let reloading = false
20
22
 
21
23
  function enqueue(action) {
22
24
  pending = pending.then(action, action)
@@ -48,6 +50,8 @@ function createBridgeController(options = {}) {
48
50
  port: settings.port,
49
51
  portFile: options.portFile,
50
52
  logger,
53
+ isWriterTokenEnforced: () => settings.writerTokenEnforced === true,
54
+ reloadModules: hotReloadModules,
51
55
  })
52
56
  }
53
57
 
@@ -99,6 +103,64 @@ function createBridgeController(options = {}) {
99
103
  await runningBridge.stop()
100
104
  }
101
105
 
106
+ /**
107
+ * Hot reload (2026-09-12): purge this plugin's lib/ require cache and
108
+ * rebuild the bridge from the on-disk code. Live instances (this
109
+ * controller, the telemetry object wired in index.js, the recorder) keep
110
+ * their closures and state; only the module table refreshes. Returns after
111
+ * validation — the swap is scheduled so the HTTP response can flush first.
112
+ */
113
+ function hotReloadModules() {
114
+ if (reloading) {
115
+ return Promise.reject(new Error('a reload is already in progress'))
116
+ }
117
+ return enqueue(async () => {
118
+ reloading = true
119
+ try {
120
+ const libRoot = __dirname
121
+ for (const key of Object.keys(require.cache)) {
122
+ if (key.startsWith(libRoot + path.sep) || key === libRoot) {
123
+ delete require.cache[key]
124
+ }
125
+ }
126
+ // Fresh require throws on a broken build → old bridge untouched.
127
+ const fresh = require('./poi-http-bridge')
128
+ const freshFactory = fresh.createPoiDataBridge
129
+ if (typeof freshFactory !== 'function') {
130
+ throw new Error('fresh poi-http-bridge does not export createPoiDataBridge')
131
+ }
132
+ // Re-read settings: the fresh settings module may normalize new keys.
133
+ const freshSettings = require('./settings')
134
+ settings = freshSettings.normalizeSettings(
135
+ freshSettings.loadSettings(settingsPath),
136
+ )
137
+ createBridge = freshFactory
138
+ const wasEnabled = settings.enabled
139
+ // Let the /admin/reload response flush on the old connection before
140
+ // it drops; the swap then runs on the controller's serial queue.
141
+ setTimeout(() => {
142
+ enqueue(async () => {
143
+ try {
144
+ await stopCurrentBridge()
145
+ if (wasEnabled) await ensureStarted()
146
+ logger.log('[poi-plugin-mcp] hot reload complete')
147
+ } catch (error) {
148
+ logger.error(`[poi-plugin-mcp] hot reload swap failed: ${error.message}`)
149
+ // Best effort: the factory is already fresh; the next
150
+ // ensureStarted (any settings apply or manual start) recovers.
151
+ try {
152
+ if (wasEnabled) await ensureStarted()
153
+ } catch (_) { /* surfaced by the next status poll */ }
154
+ }
155
+ })
156
+ }, 250)
157
+ return { port: settings.port, deferredSwap: true }
158
+ } finally {
159
+ reloading = false
160
+ }
161
+ })
162
+ }
163
+
102
164
  function persist(nextSettings) {
103
165
  settings = saveSettings(normalizeSettings(nextSettings), settingsPath)
104
166
  return settings
@@ -175,6 +237,18 @@ function createBridgeController(options = {}) {
175
237
  return { ...settings }
176
238
  },
177
239
 
240
+ /**
241
+ * 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
242
+ * lib/ code without restarting poi. Telemetry getters are re-injected
243
+ * (event generations survive); the recorder session is untouched.
244
+ *
245
+ * Ordering: the fresh modules are required BEFORE anything stops — a bad
246
+ * build throws here, the old bridge keeps serving, and the caller gets a
247
+ * 500 with the require error. The swap itself is deferred so the
248
+ * /admin/reload response flushes on the old connection before it drops.
249
+ */
250
+ reloadModules: hotReloadModules,
251
+
178
252
  getStatus() {
179
253
  const actualPort = bridge ? bridge.getPort() : 0
180
254
  const recorderStatus = recorder && typeof recorder.getStatus === 'function'
@@ -0,0 +1,259 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Per-click PIXI hit ledger (0913 admiral ruling: every dispatched click must
5
+ * name the control it landed on).
6
+ *
7
+ * Evidence contract — this ledger is a WITNESS, never a judge:
8
+ * - The snapshot is read immediately BEFORE the synthetic input event is
9
+ * queued, so a tree mutation inside the dispatch window can still make
10
+ * it stale. The authoritative "did the click take effect" signal stays
11
+ * the matching API event plus the final-state audit.
12
+ * - `eventHit` comes from the game's own hit resolution (EventSystem /
13
+ * InteractionManager hitTest) when available; `containment` is a
14
+ * world-AABB approximation whose stack makes occlusion visible.
15
+ * - Failure is fail-soft: an unavailable probe records why and never
16
+ * blocks the dispatch.
17
+ */
18
+ const fs = require('node:fs')
19
+ const path = require('node:path')
20
+
21
+ const MAX_STACK = 8
22
+ const MAX_VISIT = 4000
23
+ const MAX_DEPTH = 40
24
+ // Wall-clock bound for the whole snapshot. The page-side script has its own
25
+ // 1500ms timeout, but a throttled renderer can stall the IPC channel until
26
+ // the runtime's 10s inspection bound — a witness must never gate dispatch.
27
+ const CAPTURE_WALL_MS = 1800
28
+
29
+ function buildHitTestScript(x, y) {
30
+ return `(() => {
31
+ const px = ${JSON.stringify(x)}, py = ${JSON.stringify(y)};
32
+ const shared = globalThis.PIXI && globalThis.PIXI.ticker && globalThis.PIXI.ticker.shared;
33
+ const head = shared && shared._head;
34
+ const renderers = [];
35
+ let node = head ? head.next : null;
36
+ let guard = 0;
37
+ while (node && guard < 64) {
38
+ if (node.context && node.context.renderer) renderers.push(node.context.renderer);
39
+ node = node.next;
40
+ guard += 1;
41
+ }
42
+ const renderer = renderers.find(
43
+ (r) => r && r.view && r.view.isConnected === true && r._lastObjectRendered,
44
+ );
45
+ if (!renderer) return { available: false, reason: 'stale_renderer' };
46
+ const describe = (o) => {
47
+ const record = {};
48
+ try { record.cls = (o.constructor && o.constructor.name) || null; } catch (_) { record.cls = null; }
49
+ try { record.name = typeof o.name === 'string' && o.name ? o.name : null; } catch (_) { record.name = null; }
50
+ try {
51
+ const tex = o.texture;
52
+ record.texture = tex && typeof tex.url === 'string' ? tex.url : null;
53
+ // Atlas forensics (0913 admiral request): the sprite's frame inside its
54
+ // source atlas sheet + the sheet URL let a hit record be mapped back to
55
+ // the exact rectangle of the ORIGINAL art for offline cropping/filter.
56
+ try {
57
+ const base = tex && tex.baseTexture;
58
+ const frame = tex && tex.frame;
59
+ record.atlas = {
60
+ url: base && typeof base.imageUrl === 'string' ? base.imageUrl : null,
61
+ frame: frame
62
+ ? [
63
+ Math.round(frame.x), Math.round(frame.y),
64
+ Math.round(frame.width), Math.round(frame.height),
65
+ ]
66
+ : null,
67
+ rotate: tex && typeof tex.rotate === 'number' ? tex.rotate : 0,
68
+ };
69
+ } catch (_) { record.atlas = null; }
70
+ } catch (_) { record.texture = null; record.atlas = null; }
71
+ try {
72
+ const b = o.getBounds ? o.getBounds() : null;
73
+ record.bounds = b
74
+ ? [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]
75
+ : null;
76
+ } catch (_) { record.bounds = null; }
77
+ try {
78
+ record.interactive = o.eventMode
79
+ ? o.eventMode !== 'auto' && o.eventMode !== 'none' && o.eventMode !== 'passive'
80
+ : o.interactive === true;
81
+ } catch (_) { record.interactive = null; }
82
+ try { record.visible = o.visible !== false; } catch (_) { record.visible = null; }
83
+ try { record.alpha = typeof o.alpha === 'number' ? Math.round(o.alpha * 100) / 100 : null; } catch (_) { record.alpha = null; }
84
+ return record;
85
+ };
86
+ const chain = (o) => {
87
+ const path = [];
88
+ let cur = o;
89
+ let steps = 0;
90
+ while (cur && steps < 6) {
91
+ path.push((cur.constructor && cur.constructor.name) || 'unknown');
92
+ cur = cur.parent;
93
+ steps += 1;
94
+ }
95
+ return path;
96
+ };
97
+ // 1) The game's own hit resolution — interactive targets only.
98
+ let eventHit = null;
99
+ try {
100
+ const point = { x: px, y: py };
101
+ const events = renderer.events
102
+ || (renderer.plugins && renderer.plugins.event)
103
+ || (renderer.plugins && renderer.plugins.interaction);
104
+ if (events && typeof events.hitTest === 'function') {
105
+ const target = events.hitTest(point);
106
+ if (target) {
107
+ eventHit = describe(target);
108
+ eventHit.path = chain(target);
109
+ }
110
+ }
111
+ } catch (_) { eventHit = null; }
112
+ // 2) World-AABB containment stack — approximate paint order (later entry
113
+ // ≈ on top within its branch); makes occlusion (family pages over stale
114
+ // choice pairs) visible in the record itself.
115
+ const stack = [];
116
+ let visited = 0;
117
+ const walk = (o, depth) => {
118
+ if (!o || depth > ${MAX_DEPTH} || visited > ${MAX_VISIT} || stack.length >= ${MAX_STACK}) return;
119
+ visited += 1;
120
+ try {
121
+ if (o.visible !== false && o.renderable !== false) {
122
+ const b = o.getBounds ? o.getBounds() : null;
123
+ if (
124
+ b && px >= b.x && px <= b.x + b.width && py >= b.y && py <= b.y + b.height
125
+ ) {
126
+ stack.push(describe(o));
127
+ }
128
+ }
129
+ } catch (_) { /* a node that cannot be described is skipped, not fatal */ }
130
+ if (stack.length >= ${MAX_STACK}) return;
131
+ const kids = o.children;
132
+ if (Array.isArray(kids)) {
133
+ for (let i = 0; i < kids.length; i += 1) walk(kids[i], depth + 1);
134
+ }
135
+ };
136
+ try { walk(renderer._lastObjectRendered, 0); } catch (_) { /* partial stack is still evidence */ }
137
+ return {
138
+ available: true,
139
+ point: { x: px, y: py },
140
+ eventHit,
141
+ containmentCount: stack.length,
142
+ topmost: stack.length > 0 ? stack[stack.length - 1] : null,
143
+ stack,
144
+ };
145
+ })()`
146
+ }
147
+
148
+ function createPixiHitLedger(options = {}) {
149
+ const evaluate = options.evaluate
150
+ const listFrames = options.listFrames
151
+ const logger = options.logger || console
152
+ const now = options.now || (() => new Date())
153
+ const captureWallMs = options.captureWallMs || CAPTURE_WALL_MS
154
+ const appendFile = options.appendFile
155
+ || ((file, line) => fs.promises.appendFile(file, line, 'utf8'))
156
+ const ledgerFile = options.ledgerFile || null
157
+ let lastFrameId = null
158
+ let lastFrameLookupAt = 0
159
+ let ledgerWrite = Promise.resolve()
160
+
161
+ if (typeof evaluate !== 'function' || typeof listFrames !== 'function') {
162
+ return Object.freeze({
163
+ async capture() { return Object.freeze({ available: false, reason: 'runtime_unavailable' }) },
164
+ })
165
+ }
166
+
167
+ async function resolveGameFrameId() {
168
+ const at = Date.now()
169
+ if (lastFrameId && at - lastFrameLookupAt < 2000) return lastFrameId
170
+ const frames = await listFrames()
171
+ const game = frames.find((f) => /kcs2/i.test(f.url || '')) || frames[0]
172
+ if (!game) return null
173
+ lastFrameId = game.id
174
+ lastFrameLookupAt = at
175
+ return game.id
176
+ }
177
+
178
+ function appendToLedger(line) {
179
+ if (!ledgerFile) return
180
+ ledgerWrite = ledgerWrite
181
+ .then(() => appendFile(ledgerFile, line))
182
+ .catch((error) => {
183
+ logger.error(`[poi-plugin-mcp] hit ledger append failed: ${error.message}`)
184
+ })
185
+ }
186
+
187
+ /**
188
+ * Snapshot what the live PIXI tree shows at the operation's canonical
189
+ * point. Never throws: an unavailable probe is a recorded outcome.
190
+ */
191
+ async function capture(operation, context = {}) {
192
+ const capturedAt = now().toISOString()
193
+ const isClick = operation.operation === 'click'
194
+ const point = isClick
195
+ ? { x: operation.x, y: operation.y }
196
+ : operation.operation === 'drag'
197
+ ? { x: operation.fromX, y: operation.fromY }
198
+ : null
199
+ if (point === null) return Object.freeze({ available: false, reason: 'not_pointer' })
200
+ const record = {
201
+ at: capturedAt,
202
+ operation: operation.operation,
203
+ point,
204
+ ...(context.leaseId === undefined ? {} : { leaseId: context.leaseId }),
205
+ ...(context.ownerSessionId === undefined ? {} : { ownerSessionId: context.ownerSessionId }),
206
+ ...(context.runId === undefined ? {} : { runId: context.runId }),
207
+ ...(context.action === undefined ? {} : { action: context.action }),
208
+ }
209
+ try {
210
+ const frameId = await resolveGameFrameId()
211
+ if (frameId === null) {
212
+ return Object.freeze({ ...record, available: false, reason: 'game_frame_not_found' })
213
+ }
214
+ const response = await Promise.race([
215
+ evaluate({
216
+ frameId,
217
+ script: buildHitTestScript(point.x, point.y),
218
+ timeoutMs: 1500,
219
+ }),
220
+ new Promise((_, reject) => {
221
+ setTimeout(
222
+ () => reject(new Error('capture_wall_timeout')),
223
+ captureWallMs,
224
+ )
225
+ }),
226
+ ])
227
+ const value = response && response.value
228
+ // Every outcome is appended — a witness with gaps is worse than a
229
+ // witness with anomalies (a probe that ran but returned nothing
230
+ // non-object is itself diagnostic evidence).
231
+ const hit = value === null || typeof value !== 'object'
232
+ ? { ...record, available: false, reason: 'probe_empty' }
233
+ : { ...record, ...value }
234
+ appendToLedger(`${JSON.stringify(hit)}\n`)
235
+ return Object.freeze(hit)
236
+ } catch (error) {
237
+ const hit = { ...record, available: false, reason: `probe_failed:${error.message}` }
238
+ appendToLedger(`${JSON.stringify(hit)}\n`)
239
+ return Object.freeze(hit)
240
+ }
241
+ }
242
+
243
+ return Object.freeze({ capture })
244
+ }
245
+
246
+ function defaultLedgerFile(portFile) {
247
+ if (!portFile || typeof portFile !== 'string') return null
248
+ try {
249
+ return path.join(path.dirname(portFile), 'hit-ledger.jsonl')
250
+ } catch (_) {
251
+ return null
252
+ }
253
+ }
254
+
255
+ module.exports = {
256
+ buildHitTestScript,
257
+ createPixiHitLedger,
258
+ defaultLedgerFile,
259
+ }
@@ -5,6 +5,7 @@ const os = require('os')
5
5
  const path = require('path')
6
6
  const packageJson = require('../package.json')
7
7
  const { loadOrCreateInputToken } = require('./input-token')
8
+ const { getWriterTokenRegistry, hasValidWriterToken } = require('./writer-tokens')
8
9
  const { createPoiInputProvider } = require('./poi-input')
9
10
  const { createPoiInputLease } = require('./poi-input-lease')
10
11
  const { createPoiScreenshotProvider } = require('./poi-screenshot')
@@ -19,6 +20,10 @@ const {
19
20
  speedFromRaw,
20
21
  speedMeaning,
21
22
  } = require('./fleet-metrics')
23
+ const {
24
+ createPixiHitLedger,
25
+ defaultLedgerFile,
26
+ } = require('./pixi-hit-ledger')
22
27
 
23
28
  const DEFAULT_PORT = 17777
24
29
  const DEFAULT_PORT_FILE = path.join(os.homedir(), '.poi-mcp', 'port')
@@ -107,6 +112,14 @@ function createPoiDataBridge(options = {}) {
107
112
  inputEnabled ? loadOrCreateInputToken(options.inputTokenFile) : null
108
113
  )
109
114
  const inputLease = options.inputLease || createPoiInputLease(options.inputLeaseOptions)
115
+ // 2026-09-12 Phase C: daemon-delegated writer tokens gate the INPUT
116
+ // surface (clicks, lease takes, debug-eval). Module-level singleton so
117
+ // settings-triggered bridge re-creation keeps the registered set; a hot
118
+ // reload purges the module, and the daemon's periodic re-registration
119
+ // self-heals within one idle cycle.
120
+ const writerTokens = options.writerTokens || getWriterTokenRegistry()
121
+ const isWriterTokenEnforced = options.isWriterTokenEnforced ||
122
+ (() => options.writerTokenEnforced === true)
110
123
  let captureScreenshot = options.captureScreenshot || null
111
124
  let performInput = options.performInput || null
112
125
  let dataQuery = options.dataQuery || null
@@ -116,6 +129,33 @@ function createPoiDataBridge(options = {}) {
116
129
  let inputPending = Promise.resolve()
117
130
  const pendingActionEventWaits = new Set()
118
131
 
132
+ /**
133
+ * Writer-token gate for input-capable endpoints. Callers have already
134
+ * validated the static bearer (transport auth). Three outcomes:
135
+ * registered token -> pass; missing/rejected token + enforcement -> 403
136
+ * with a reason a stale process can act on; missing token in observation
137
+ * mode -> pass and count (legacyAuth), which is the bridge-side
138
+ * foreign-writer signal.
139
+ */
140
+ function authorizeWriterInput(req) {
141
+ const headerValue = req.headers['x-writer-token']
142
+ if (hasValidWriterToken(headerValue, writerTokens)) {
143
+ return { ok: true }
144
+ }
145
+ if (isWriterTokenEnforced()) {
146
+ return {
147
+ ok: false,
148
+ status: 403,
149
+ error:
150
+ 'Writer token rejected: no registered X-Writer-Token matches. ' +
151
+ 'Stale process from a previous daemon generation? Re-submit the work ' +
152
+ 'through the daemon (kc daemon submit) or renew the token.',
153
+ }
154
+ }
155
+ writerTokens.noteLegacyAuth(Date.now(), `${req.method} ${req.url}`)
156
+ return { ok: true, legacy: true }
157
+ }
158
+
119
159
  function readStore() {
120
160
  const store = getStore()
121
161
  if (!store || !store.info) {
@@ -160,6 +200,19 @@ function createPoiDataBridge(options = {}) {
160
200
  return dataQuery
161
201
  }
162
202
 
203
+ let hitLedger = null
204
+ function currentHitLedger() {
205
+ if (!hitLedger) {
206
+ hitLedger = createPixiHitLedger({
207
+ evaluate: (request) => currentDataQuery().runtime.evaluate(request),
208
+ listFrames: () => currentDataQuery().runtime.listFrames(),
209
+ ledgerFile: defaultLedgerFile(portFile),
210
+ logger,
211
+ })
212
+ }
213
+ return hitLedger
214
+ }
215
+
163
216
  async function handleDataRequest(req, res, endpoint) {
164
217
  if (req.method !== 'POST') {
165
218
  drainRequest(req)
@@ -176,6 +229,17 @@ function createPoiDataBridge(options = {}) {
176
229
  )
177
230
  return
178
231
  }
232
+ // /debug/evaluate executes arbitrary JS in the game WebView — it is a
233
+ // write-capable channel and takes the writer-token gate. /query is a
234
+ // read and stays static-bearer (diagnostics keep working).
235
+ if (endpoint === '/debug/evaluate') {
236
+ const writer = authorizeWriterInput(req)
237
+ if (!writer.ok) {
238
+ drainRequest(req)
239
+ sendInputJson(res, writer.status, { error: writer.error })
240
+ return
241
+ }
242
+ }
179
243
  try {
180
244
  const body = await readRequestBody(
181
245
  req,
@@ -218,10 +282,28 @@ function createPoiDataBridge(options = {}) {
218
282
  if (!performInput) {
219
283
  performInput = createPoiInputProvider({ getStore })
220
284
  }
285
+ // 0913 per-click hit ledger (admiral ruling): snapshot what the live
286
+ // PIXI tree shows at the pointer before dispatch, gated by the same
287
+ // debugEvalEnabled setting that owns WebView evaluation. Evidence
288
+ // only — the response field and ~/.poi-mcp/hit-ledger.jsonl never
289
+ // gate the dispatch itself (capture is fail-soft and wall-bounded).
290
+ let hit
291
+ if (
292
+ debugEvalEnabled === true &&
293
+ (operation.operation === 'click' || operation.operation === 'drag')
294
+ ) {
295
+ hit = await currentHitLedger().capture(operation, {
296
+ leaseId: claim.leaseId,
297
+ ownerSessionId: claim.ownerSessionId,
298
+ runId: claim.runId,
299
+ action: claim.action,
300
+ })
301
+ }
221
302
  const operationName = await performInput(operation)
222
303
  return {
223
304
  ok: true,
224
305
  operation: operationName,
306
+ ...(hit === undefined ? {} : { hit }),
225
307
  sequence: claim.sequence,
226
308
  leaseId: claim.leaseId,
227
309
  ownerSessionId: claim.ownerSessionId,
@@ -261,6 +343,12 @@ function createPoiDataBridge(options = {}) {
261
343
  sendInputJson(res, 403, { error: 'WebView input is disabled.' })
262
344
  return
263
345
  }
346
+ const writer = authorizeWriterInput(req)
347
+ if (!writer.ok) {
348
+ drainRequest(req)
349
+ sendInputJson(res, writer.status, { error: writer.error })
350
+ return
351
+ }
264
352
 
265
353
  try {
266
354
  const body = await readRequestBody(
@@ -295,6 +383,24 @@ function createPoiDataBridge(options = {}) {
295
383
  )
296
384
  return
297
385
  }
386
+ // Lease MUTATIONS (acquire/renew/release/revoke) are input-adjacent —
387
+ // they gate who may click — so the writer-token applies. The plain
388
+ // GET /input/lease status read stays static-bearer (live 0912: the
389
+ // dashboard's takeover-button poll flooded the legacy counter and would
390
+ // 403 under enforcement; reads must not take the writer gate).
391
+ const leaseMutation =
392
+ endpoint === '/input/lease/acquire' ||
393
+ endpoint === '/input/lease/renew' ||
394
+ endpoint === '/input/lease/release' ||
395
+ endpoint === '/input/lease/revoke'
396
+ if (leaseMutation) {
397
+ const leaseWriter = authorizeWriterInput(req)
398
+ if (!leaseWriter.ok) {
399
+ drainRequest(req)
400
+ sendInputJson(res, leaseWriter.status, { error: leaseWriter.error })
401
+ return
402
+ }
403
+ }
298
404
  if (endpoint === '/input/lease') {
299
405
  if (req.method !== 'GET') {
300
406
  drainRequest(req)
@@ -527,6 +633,79 @@ function createPoiDataBridge(options = {}) {
527
633
  return
528
634
  }
529
635
 
636
+ // 2026-09-12 Phase C: writer-token administration. The static bearer is
637
+ // the ADMIN credential here — exactly one caller (the daemon) uses it
638
+ // to delegate; input endpoints no longer accept it as a writer.
639
+ if (endpoint === '/writer-tokens') {
640
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
641
+ drainRequest(req)
642
+ sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
643
+ return
644
+ }
645
+ if (req.method === 'GET') {
646
+ sendInputJson(res, 200, {
647
+ enforced: isWriterTokenEnforced(),
648
+ ...writerTokens.status(),
649
+ })
650
+ return
651
+ }
652
+ if (req.method !== 'PUT') {
653
+ drainRequest(req)
654
+ sendInputJson(res, 405, { error: '/writer-tokens accepts GET or PUT.' })
655
+ return
656
+ }
657
+ try {
658
+ const body = await readRequestBody(
659
+ req,
660
+ MAX_QUERY_BODY_BYTES,
661
+ 'Writer token request body exceeds 64KB.',
662
+ )
663
+ const payload = JSON.parse(body || '{}')
664
+ const outcome = writerTokens.register(payload)
665
+ sendInputJson(res, 200, { ok: true, ...outcome, ...writerTokens.status() })
666
+ } catch (error) {
667
+ sendInputJson(res, 400, { error: error.message })
668
+ }
669
+ return
670
+ }
671
+
672
+ // 2026-09-12 hot reload: rebuild the HTTP/input layer from the on-disk
673
+ // lib/ code without restarting poi. Telemetry state and the recorder
674
+ // session survive (the controller re-injects the same getters); open
675
+ // connections drop and clients reconnect.
676
+ if (endpoint === '/admin/reload') {
677
+ if (!hasValidBearerToken(req.headers.authorization, inputToken)) {
678
+ drainRequest(req)
679
+ sendInputJson(res, 401, { error: 'A valid Bearer token is required.' })
680
+ return
681
+ }
682
+ if (req.method !== 'POST') {
683
+ drainRequest(req)
684
+ sendInputJson(res, 405, { error: '/admin/reload only accepts POST.' })
685
+ return
686
+ }
687
+ drainRequest(req)
688
+ if (typeof options.reloadModules !== 'function') {
689
+ sendInputJson(res, 501, { error: 'reloadModules is not wired by this controller build' })
690
+ return
691
+ }
692
+ try {
693
+ const outcome = await options.reloadModules()
694
+ // After a successful reload THIS bridge instance is stopped and the
695
+ // fresh one owns the port — report the outcome's port, not the
696
+ // stale local getPort().
697
+ sendInputJson(res, 200, {
698
+ ok: true,
699
+ reloaded: true,
700
+ port: (outcome && outcome.port) || getPort(),
701
+ ...(outcome || {}),
702
+ })
703
+ } catch (error) {
704
+ sendInputJson(res, 500, { ok: false, error: error.message })
705
+ }
706
+ return
707
+ }
708
+
530
709
  if (endpoint === '/screenshot') {
531
710
  if (req.method !== 'GET') {
532
711
  sendJson(
@@ -769,6 +948,15 @@ function createPoiDataBridge(options = {}) {
769
948
  actualPort = 0
770
949
  resolve()
771
950
  })
951
+ // Keep-alive clients (the daemon holds persistent connections) never
952
+ // let close() finish on their own — reap them, but only after the
953
+ // aborted long-poll responses had a turn to flush (live 0912: an
954
+ // immediate reap raced the wait-abort writes into ECONNRESET).
955
+ setImmediate(() => {
956
+ if (typeof closingServer.closeAllConnections === 'function') {
957
+ closingServer.closeAllConnections()
958
+ }
959
+ })
772
960
  })
773
961
  }
774
962
 
package/lib/settings.js CHANGED
@@ -8,6 +8,12 @@ const DEFAULT_SETTINGS = Object.freeze({
8
8
  inputEnabled: false,
9
9
  recordingEnabled: false,
10
10
  debugEvalEnabled: false,
11
+ // 2026-09-12 Phase C: when true, INPUT endpoints (/input, /input/lease*,
12
+ // /debug/evaluate) require a registered X-Writer-Token in addition to the
13
+ // static bearer. Observation mode (false) serves legacy static-bearer
14
+ // requests but counts them — the daemon registers tokens and sends them
15
+ // either way, so flipping this only rejects leftovers.
16
+ writerTokenEnforced: false,
11
17
  })
12
18
 
13
19
  const DEFAULT_SETTINGS_FILE = path.join(os.homedir(), '.poi-mcp', 'settings.json')
@@ -26,6 +32,9 @@ function normalizeSettings(input = {}) {
26
32
  debugEvalEnabled: typeof input.debugEvalEnabled === 'boolean'
27
33
  ? input.debugEvalEnabled
28
34
  : DEFAULT_SETTINGS.debugEvalEnabled,
35
+ writerTokenEnforced: typeof input.writerTokenEnforced === 'boolean'
36
+ ? input.writerTokenEnforced
37
+ : DEFAULT_SETTINGS.writerTokenEnforced,
29
38
  }
30
39
  }
31
40
 
@@ -0,0 +1,133 @@
1
+ const crypto = require('crypto')
2
+
3
+ const TOKEN_PATTERN = /^[a-f0-9]{32,128}$/
4
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
5
+
6
+ /**
7
+ * Daemon-delegated writer tokens (2026-09-12 single-writer plan, Phase C).
8
+ *
9
+ * The static input token authorizes whoever can read the file — forever. A
10
+ * zombie from an earlier session therefore holds a valid credential forever.
11
+ * This registry replaces that for INPUT endpoints: the daemon mints short
12
+ * lived tokens (a generation token for itself, one per spawned job), pushes
13
+ * them here over the admin endpoint, and revokes job tokens when the reaper
14
+ * collects the job. A process that missed a rotation is rejected with a
15
+ * readable reason instead of silently competing for clicks.
16
+ *
17
+ * Module-level singleton: bridge restarts (settings apply, hot reload) must
18
+ * not silently drop the registered set — and when a hot reload DOES purge
19
+ * this module, the daemon's periodic re-registration self-heals within one
20
+ * idle cycle. The threat model is accidents, not adversaries: holding a
21
+ * current token is a sufficient proxy for "delegated by the current daemon
22
+ * generation", so there is deliberately no per-pid challenge/response.
23
+ */
24
+ function createWriterTokenRegistry() {
25
+ const entries = new Map() // token -> { label, registeredAt, expiresAt }
26
+ let legacyAuthCount = 0
27
+ let lastLegacyAuthAt = null
28
+ let lastLegacyAuthDetail = null
29
+
30
+ function sweep(now = Date.now()) {
31
+ for (const [token, entry] of entries) {
32
+ if (entry.expiresAt <= now) entries.delete(token)
33
+ }
34
+ }
35
+
36
+ return {
37
+ register(payload, now = Date.now()) {
38
+ const mode = payload && (payload.mode === 'add' || payload.mode === 'remove') ? payload.mode : 'replace'
39
+ const list = payload && Array.isArray(payload.tokens) ? payload.tokens : []
40
+ if (mode === 'replace') entries.clear()
41
+ let accepted = 0
42
+ let rejected = []
43
+ for (const item of list) {
44
+ const token = item && typeof item.token === 'string' ? item.token : null
45
+ if (!TOKEN_PATTERN.test(token || '')) {
46
+ rejected.push('invalid token shape')
47
+ continue
48
+ }
49
+ const ttlMs = Number.isFinite(item.ttlMs) && item.ttlMs > 0 && item.ttlMs <= 7 * DEFAULT_TTL_MS
50
+ ? Math.trunc(item.ttlMs)
51
+ : DEFAULT_TTL_MS
52
+ entries.set(token, {
53
+ label: typeof item.label === 'string' && item.label !== '' ? item.label.slice(0, 128) : 'unlabeled',
54
+ registeredAt: now,
55
+ expiresAt: now + ttlMs,
56
+ })
57
+ accepted += 1
58
+ }
59
+ if (mode === 'remove') {
60
+ for (const item of list) {
61
+ if (item && typeof item.label === 'string') {
62
+ for (const [token, entry] of entries) {
63
+ if (entry.label === item.label) entries.delete(token)
64
+ }
65
+ }
66
+ }
67
+ }
68
+ sweep(now)
69
+ return { accepted, rejected: rejected.length }
70
+ },
71
+
72
+ validate(token, now = Date.now()) {
73
+ if (typeof token !== 'string' || !TOKEN_PATTERN.test(token)) return false
74
+ const entry = entries.get(token)
75
+ if (!entry) return false
76
+ if (entry.expiresAt <= now) {
77
+ entries.delete(token)
78
+ return false
79
+ }
80
+ return true
81
+ },
82
+
83
+ noteLegacyAuth(now = Date.now(), detail = null) {
84
+ legacyAuthCount += 1
85
+ lastLegacyAuthAt = new Date(now).toISOString()
86
+ lastLegacyAuthDetail = typeof detail === 'string' && detail !== '' ? detail.slice(0, 160) : null
87
+ },
88
+
89
+ status(now = Date.now()) {
90
+ sweep(now)
91
+ return {
92
+ registered: entries.size,
93
+ tokens: [...entries.values()].map((entry) => ({
94
+ label: entry.label,
95
+ registeredAt: new Date(entry.registeredAt).toISOString(),
96
+ expiresAt: new Date(entry.expiresAt).toISOString(),
97
+ })),
98
+ legacyAuthCount,
99
+ lastLegacyAuthAt,
100
+ lastLegacyAuthDetail,
101
+ }
102
+ },
103
+ }
104
+ }
105
+
106
+ let singleton = null
107
+
108
+ function getWriterTokenRegistry() {
109
+ if (!singleton) singleton = createWriterTokenRegistry()
110
+ return singleton
111
+ }
112
+
113
+ function mintWriterToken() {
114
+ return crypto.randomBytes(32).toString('hex')
115
+ }
116
+
117
+ function hasValidWriterToken(headerValue, registry, now = Date.now()) {
118
+ if (typeof headerValue !== 'string' || headerValue === '') return false
119
+ // In-process registry lookup: the timing-safe compare discipline applies to
120
+ // the STATIC bearer token (a file secret); membership here is an in-memory
121
+ // Map hit against an unregistered attacker-chosen value with no secret to
122
+ // leak.
123
+ return registry.validate(headerValue, now)
124
+ }
125
+
126
+ module.exports = {
127
+ TOKEN_PATTERN,
128
+ DEFAULT_TTL_MS,
129
+ createWriterTokenRegistry,
130
+ getWriterTokenRegistry,
131
+ mintWriterToken,
132
+ hasValidWriterToken,
133
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-mcp",
3
- "version": "0.2.25",
3
+ "version": "0.2.27",
4
4
  "description": "Poi data, WebView capture, and opt-in authenticated input bridge for local KanColle tools.",
5
5
  "main": "index.js",
6
6
  "keywords": [