thinkpool-pair 0.7.364 → 0.7.366

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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
package/acp-client.mjs CHANGED
@@ -1,140 +1 @@
1
- // Runtime-neutral supervised ACP stdio client. Protocol framing, JSON-RPC,
2
- // cancellation, typed client handlers, and schemas come from the official ACP
3
- // SDK. ThinkPool owns only subprocess supervision and its stricter stdout rule.
4
- import { spawn } from 'node:child_process'
5
- import { Readable, Transform, Writable } from 'node:stream'
6
- import * as acp from '@agentclientprotocol/sdk'
7
-
8
- const closedError = (value, fallback = 'ACP process closed') => value instanceof Error
9
- ? value
10
- : new Error(String(value || fallback))
11
-
12
- function strictProtocolGate(onError) {
13
- let buffered = ''
14
- const validate = (line) => {
15
- if (!line.trim()) return
16
- let message
17
- try { message = JSON.parse(line) } catch { throw new Error('ACP stdout contained non-JSON protocol data') }
18
- if (!message || typeof message !== 'object' || Array.isArray(message)) {
19
- throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
20
- }
21
- const hasId = Object.prototype.hasOwnProperty.call(message, 'id')
22
- const hasMethod = typeof message.method === 'string' && message.method.length > 0
23
- const hasResult = Object.prototype.hasOwnProperty.call(message, 'result')
24
- const hasError = Object.prototype.hasOwnProperty.call(message, 'error')
25
- const isCall = hasMethod && !(hasResult || hasError)
26
- const isResponse = hasId && !hasMethod && hasResult !== hasError
27
- if (message.jsonrpc !== '2.0' || (!isCall && !isResponse)) {
28
- throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
29
- }
30
- }
31
- const gate = new Transform({
32
- transform(chunk, _encoding, callback) {
33
- try {
34
- buffered += String(chunk)
35
- const lines = buffered.split('\n')
36
- buffered = lines.pop() || ''
37
- for (const line of lines) validate(line)
38
- callback(null, chunk)
39
- } catch (error) { callback(error) }
40
- },
41
- flush(callback) {
42
- try { validate(buffered); callback(null, buffered || undefined) }
43
- catch (error) { callback(error) }
44
- },
45
- })
46
- gate.on('error', onError)
47
- return gate
48
- }
49
-
50
- export class AcpClient {
51
- constructor({ command, args = ['acp'], cwd, env = process.env, spawnImpl = spawn, onNotification, onRequest, onStderr, onClose, timeoutMs = 10_000 } = {}) {
52
- this.command = command
53
- this.args = args
54
- this.cwd = cwd
55
- this.env = env
56
- this.spawnImpl = spawnImpl
57
- this.onNotification = onNotification
58
- this.onRequest = onRequest
59
- this.onStderr = onStderr
60
- this.onClose = onClose
61
- this.timeoutMs = timeoutMs
62
- this.child = null
63
- this.connection = null
64
- this.closedError = null
65
- this.stderrTail = ''
66
- }
67
-
68
- get alive() { return !!this.child && !!this.connection && !this.closedError }
69
-
70
- start() {
71
- if (this.child) return
72
- const child = this.spawnImpl(this.command, this.args, { cwd: this.cwd, env: this.env, stdio: ['pipe', 'pipe', 'pipe'] })
73
- this.child = child
74
- child.stderr?.on?.('data', (chunk) => {
75
- const text = String(chunk)
76
- this.stderrTail = (this.stderrTail + text).slice(-2000)
77
- try { this.onStderr?.(text) } catch { /* consumer isolation */ }
78
- })
79
-
80
- const failProtocol = (error) => {
81
- const failure = closedError(error, 'ACP protocol stream failed')
82
- this.connection?.close(failure)
83
- this._close(failure)
84
- try { child.kill('SIGTERM') } catch { /* noop */ }
85
- }
86
- const protocolOutput = strictProtocolGate(failProtocol)
87
- child.stdout.pipe(protocolOutput)
88
- const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(protocolOutput))
89
- const app = acp.client({ name: 'thinkpool-pair' })
90
- .onRequest(acp.methods.client.session.requestPermission, ({ params, requestId }) => {
91
- if (!this.onRequest) throw Object.assign(new Error('Unsupported ACP client method: session/request_permission'), { code: -32601 })
92
- return this.onRequest(acp.methods.client.session.requestPermission, params, requestId)
93
- })
94
- .onNotification(acp.methods.client.session.update, ({ params }) => {
95
- try { this.onNotification?.(acp.methods.client.session.update, params) } catch { /* consumer isolation */ }
96
- })
97
- this.connection = app.connect(stream)
98
- void this.connection.closed.then(() => {
99
- if (!this.closedError) this._close(closedError(this.connection?.signal?.reason))
100
- })
101
-
102
- child.stdin?.on?.('error', (error) => this._close(error))
103
- child.on('error', (error) => this._close(error))
104
- child.on('close', (code, signal) => this._close(new Error(`ACP process exited ${code ?? signal ?? 'unknown'}${this.stderrTail ? `: ${this.stderrTail.trim().slice(-300)}` : ''}`)))
105
- }
106
-
107
- request(method, params = {}, timeoutMs = this.timeoutMs) {
108
- if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
109
- const controller = new AbortController()
110
- const request = this.connection.agent.request(method, params, { cancellationSignal: controller.signal })
111
- if (!(timeoutMs > 0)) return request
112
- return new Promise((resolve, reject) => {
113
- const timer = setTimeout(() => {
114
- controller.abort(new Error(`${method} timed out after ${timeoutMs}ms`))
115
- reject(new Error(`${method} timed out after ${timeoutMs}ms`))
116
- }, timeoutMs)
117
- timer.unref?.()
118
- request.then(resolve, reject).finally(() => clearTimeout(timer))
119
- })
120
- }
121
-
122
- notify(method, params) {
123
- if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
124
- return this.connection.agent.notify(method, params)
125
- }
126
-
127
- _close(error) {
128
- if (this.closedError) return
129
- this.closedError = closedError(error)
130
- this.child = null
131
- this.connection?.close(this.closedError)
132
- try { this.onClose?.(this.closedError) } catch { /* consumer isolation */ }
133
- }
134
-
135
- end() {
136
- const child = this.child
137
- this._close(new Error('ACP client ended'))
138
- if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } }
139
- }
140
- }
1
+ import{spawn as t}from"node:child_process";import{Readable as o,Transform as e,Writable as r}from"node:stream";import*as s from"@agentclientprotocol/sdk";const n=(t,o="ACP process closed")=>t instanceof Error?t:new Error(String(t||o));export class AcpClient{constructor({command:o,args:e=["acp"],cwd:r,env:s=process.env,spawnImpl:n=t,onNotification:i,onRequest:c,onStderr:l,onClose:h,timeoutMs:a=1e4}={}){this.command=o,this.args=e,this.cwd=r,this.env=s,this.spawnImpl=n,this.onNotification=i,this.onRequest=c,this.onStderr=l,this.onClose=h,this.timeoutMs=a,this.child=null,this.connection=null,this.closedError=null,this.stderrTail=""}get alive(){return!!this.child&&!!this.connection&&!this.closedError}start(){if(this.child)return;const t=this.spawnImpl(this.command,this.args,{cwd:this.cwd,env:this.env,stdio:["pipe","pipe","pipe"]});this.child=t,t.stderr?.on?.("data",t=>{const o=String(t);this.stderrTail=(this.stderrTail+o).slice(-2e3);try{this.onStderr?.(o)}catch{}});const i=function(t){let o="";const r=t=>{if(!t.trim())return;let o;try{o=JSON.parse(t)}catch{throw new Error("ACP stdout contained non-JSON protocol data")}if(!o||"object"!=typeof o||Array.isArray(o))throw new Error("ACP stdout contained invalid JSON-RPC protocol data");const e=Object.prototype.hasOwnProperty.call(o,"id"),r="string"==typeof o.method&&o.method.length>0,s=Object.prototype.hasOwnProperty.call(o,"result"),n=Object.prototype.hasOwnProperty.call(o,"error"),i=r&&!(s||n),c=e&&!r&&s!==n;if("2.0"!==o.jsonrpc||!i&&!c)throw new Error("ACP stdout contained invalid JSON-RPC protocol data")},s=new e({transform(t,e,s){try{o+=String(t);const e=o.split("\n");o=e.pop()||"";for(const t of e)r(t);s(null,t)}catch(t){s(t)}},flush(t){try{r(o),t(null,o||void 0)}catch(o){t(o)}}});return s.on("error",t),s}(o=>{const e=n(o,"ACP protocol stream failed");this.connection?.close(e),this._close(e);try{t.kill("SIGTERM")}catch{}});t.stdout.pipe(i);const c=s.ndJsonStream(r.toWeb(t.stdin),o.toWeb(i)),l=s.client({name:"thinkpool-pair"}).onRequest(s.methods.client.session.requestPermission,({params:t,requestId:o})=>{if(!this.onRequest)throw Object.assign(new Error("Unsupported ACP client method: session/request_permission"),{code:-32601});return this.onRequest(s.methods.client.session.requestPermission,t,o)}).onNotification(s.methods.client.session.update,({params:t})=>{try{this.onNotification?.(s.methods.client.session.update,t)}catch{}});this.connection=l.connect(c),this.connection.closed.then(()=>{this.closedError||this._close(n(this.connection?.signal?.reason))}),t.stdin?.on?.("error",t=>this._close(t)),t.on("error",t=>this._close(t)),t.on("close",(t,o)=>this._close(new Error(`ACP process exited ${t??o??"unknown"}${this.stderrTail?`: ${this.stderrTail.trim().slice(-300)}`:""}`)))}request(t,o={},e=this.timeoutMs){if(!this.connection||this.closedError)return Promise.reject(this.closedError||new Error("ACP process is not running"));const r=new AbortController,s=this.connection.agent.request(t,o,{cancellationSignal:r.signal});return e>0?new Promise((o,n)=>{const i=setTimeout(()=>{r.abort(new Error(`${t} timed out after ${e}ms`)),n(new Error(`${t} timed out after ${e}ms`))},e);i.unref?.(),s.then(o,n).finally(()=>clearTimeout(i))}):s}notify(t,o){return!this.connection||this.closedError?Promise.reject(this.closedError||new Error("ACP process is not running")):this.connection.agent.notify(t,o)}_close(t){if(!this.closedError){this.closedError=n(t),this.child=null,this.connection?.close(this.closedError);try{this.onClose?.(this.closedError)}catch{}}}end(){const t=this.child;if(this._close(new Error("ACP client ended")),t)try{t.kill("SIGTERM")}catch{}}}
package/agent-detect.mjs CHANGED
@@ -1,28 +1 @@
1
- // One installed-agent detector shared by the room bridge and account supervisor.
2
- // Both surfaces announce the same machine; they must not disagree about which
3
- // coding CLIs it can open merely because they publish on different channels.
4
- import fs from 'node:fs'
5
- import path from 'node:path'
6
-
7
- export const KNOWN_AGENT_COMMANDS = [
8
- 'claude', 'codex', 'thinkpool', 'gemini', 'aider', 'cursor-agent',
9
- 'opencode', 'copilot', 'goose', 'crush', 'qwen',
10
- ]
11
-
12
- export function commandOnPath(command, { env = process.env, platform = process.platform, fsImpl = fs } = {}) {
13
- const exts = platform === 'win32'
14
- ? (env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
15
- : ['']
16
- const delimiter = platform === 'win32' ? ';' : path.delimiter
17
- for (const dir of (env.PATH || '').split(delimiter)) {
18
- if (!dir) continue
19
- for (const ext of exts) {
20
- try { if (fsImpl.existsSync(path.join(dir, command + ext))) return true } catch { /* unreadable path entry */ }
21
- }
22
- }
23
- return false
24
- }
25
-
26
- export function installedAgentCommands(options) {
27
- return KNOWN_AGENT_COMMANDS.filter((command) => commandOnPath(command, options))
28
- }
1
+ import o from"node:fs";import n from"node:path";export const KNOWN_AGENT_COMMANDS=["claude","codex","thinkpool","gemini","aider","cursor-agent","opencode","copilot","goose","crush","qwen"];export function commandOnPath(t,{env:e=process.env,platform:r=process.platform,fsImpl:i=o}={}){const c="win32"===r?(e.PATHEXT||".EXE;.CMD;.BAT;.COM").split(";"):[""],s="win32"===r?";":n.delimiter;for(const o of(e.PATH||"").split(s))if(o)for(const e of c)try{if(i.existsSync(n.join(o,t+e)))return!0}catch{}return!1}export function installedAgentCommands(o){return KNOWN_AGENT_COMMANDS.filter(n=>commandOnPath(n,o))}
package/agent-notify.mjs CHANGED
@@ -1,142 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- bridge/agent-notify.mjs — the PURE decision layer for agent push events.
3
-
4
- The bridge is the only process that knows a turn ended or a permission
5
- card went unanswered. It can't call /api/notify-code directly (that
6
- endpoint is webhook-secret-gated and this is a published npm package), so
7
- it writes a compact `__agent` row into code_messages and lets the existing
8
- push_notify_on_code_message trigger fan it out.
9
-
10
- Everything that DECIDES whether to write such a row lives here, with no
11
- I/O, so bridge/agent-notify.test.mjs can exercise it without a room, a
12
- socket, or a wall clock.
13
-
14
- Spec: docs/specs/2026-07-09-code-notifications-rework.md (Slice 3)
15
- ───────────────────────────────────────────────────────────── */
16
-
17
- // A turn shorter than this means somebody was almost certainly watching it —
18
- // pushing "done" 4 seconds after they hit enter is noise, not news.
19
- export const DEFAULT_MIN_TURN_MS = 30_000
20
-
21
- // How long a permission card may sit unanswered before we assume nobody is
22
- // looking at the room and push. Short enough to be useful, long enough that
23
- // the common case (you're right there, you click it) never fires.
24
- export const DEFAULT_PERM_GRACE_MS = 20_000
25
-
26
- // Push copy is a banner, not a transcript.
27
- export const SUMMARY_MAX = 200
28
-
29
- /* A lane is USER-FACING when a human opened it and is waiting on it.
30
- - spawnedBy: a Dispatch worker lane an agent fanned out. Its owner is
31
- another agent; the human never asked for it directly.
32
- - flowTaskKey: a Flow slice lane. Same reasoning — the conductor collects
33
- it, and a 12-lane flow would fan 12 banners at one person.
34
- Both run in inherited/bypass modes almost always, so they also rarely
35
- raise permission cards. The conductor lane itself (flowSessionId but no
36
- flowTaskKey) IS user-facing — that's the one a human is watching. */
37
- export function isUserFacingLane(entry) {
38
- if (!entry) return false
39
- return !entry.spawnedBy && !entry.flowTaskKey
40
- }
41
-
42
- /* Collapse whitespace, clip, ellipsize. Never throws on non-strings. */
43
- export function clipSummary(text, max = SUMMARY_MAX) {
44
- if (text == null) return ''
45
- const s = String(text).replace(/\s+/g, ' ').trim()
46
- if (s.length <= max) return s
47
- return s.slice(0, max).replace(/\s+\S*$/, '').trimEnd() + '…'
48
- }
49
-
50
- /* Should a settled turn produce a `turn-done` event row?
51
-
52
- No when: the lane isn't user-facing · the user pressed Stop (subtype
53
- 'aborted' — they know it's over, they're the one who ended it) · the turn
54
- was short enough that somebody was plainly watching · we never saw the
55
- turn start (startedAt null → a stray `error` event outside any turn, e.g.
56
- the SDK-regression warning emitted at init).
57
-
58
- `error` turns DO notify — with the same neutral copy as a success. Max's
59
- call: "the agent stopped" is the news; the reason is in the room. */
60
- export function shouldNotifyTurnDone({ entry, subtype, startedAt, now, minTurnMs = DEFAULT_MIN_TURN_MS }) {
61
- if (!shouldRecordTurnDone({ entry, subtype, startedAt, now })) return false
62
- return (now - startedAt) >= minTurnMs
63
- }
64
-
65
- // Every user-facing settle is durable realtime evidence for the in-app rail.
66
- // The older 30s threshold remains only a PUSH eligibility rule; using it as the
67
- // write gate made short turns in another session invisible to the web app.
68
- export function shouldRecordTurnDone({ entry, subtype, startedAt, now }) {
69
- if (!isUserFacingLane(entry)) return false
70
- if (subtype === 'aborted') return false
71
- if (!startedAt) return false
72
- return Number.isFinite(now)
73
- }
74
-
75
- /* The one-line "what is it asking me" for a permission card.
76
- AskUserQuestion cards carry `questions`; ordinary tool cards degrade to
77
- `<ToolName> <first meaningful arg>`, matching the local stderr line. */
78
- export function permissionSummary(payload) {
79
- const p = payload || {}
80
- const qs = Array.isArray(p.questions) ? p.questions.map(q => q?.question).filter(Boolean) : []
81
- if (qs.length) return clipSummary(qs.join(' / '))
82
- if (p.risk === 'plan') return clipSummary(p.plan ? `Plan ready: ${p.plan}` : 'A plan is ready for review')
83
- const i = p.input
84
- const arg = i && (i.command || i.file_path || i.path || i.pattern || i.url || i.query || i.description)
85
- const name = p.toolName || 'A tool'
86
- return clipSummary(arg ? `${name} ${arg}` : name)
87
- }
88
-
89
- /* Per-session permission-card notifier.
90
-
91
- arm(id, payload) — a card was raised. Start the grace timer.
92
- resolve(id) — the card was answered (human click, mode-flip
93
- auto-approval, or a drain-to-deny). If its timer had
94
- already FIRED we pushed a banner, so retract it.
95
- clearAll() — turn ended / session died. Every outstanding card is
96
- dead; retract anything we announced.
97
-
98
- Timers are injected so the unit test drives them synchronously. onFire /
99
- onRetract are fire-and-forget (they end up as REST inserts); a throw in
100
- either must never take the bridge down. */
101
- export function createPermNotifier({
102
- graceMs = DEFAULT_PERM_GRACE_MS,
103
- onArm = () => {},
104
- onFire = () => {},
105
- onRetract = () => {},
106
- onResolve = () => {},
107
- setTimer = setTimeout,
108
- clearTimer = clearTimeout,
109
- } = {}) {
110
- const cards = new Map() // id → { timer, fired, payload }
111
- const safe = (fn, id, payload) => { try { fn(id, payload) } catch { /* never break the turn */ } }
112
-
113
- const settle = (id) => {
114
- const rec = cards.get(id)
115
- if (!rec) return
116
- if (rec.timer) { try { clearTimer(rec.timer) } catch { /* noop */ } }
117
- cards.delete(id)
118
- if (rec.fired) safe(onRetract, id, rec.payload)
119
- safe(onResolve, id, rec.payload)
120
- }
121
-
122
- return {
123
- arm(id, payload) {
124
- if (!id || cards.has(id)) return
125
- const rec = { timer: null, fired: false, payload }
126
- safe(onArm, id, payload)
127
- rec.timer = setTimer(() => {
128
- rec.timer = null
129
- rec.fired = true
130
- safe(onFire, id, payload)
131
- }, graceMs)
132
- // Never hold the process open on a permission timer.
133
- try { rec.timer?.unref?.() } catch { /* not a node timer (test double) */ }
134
- cards.set(id, rec)
135
- },
136
- resolve: settle,
137
- clearAll() { for (const id of [...cards.keys()]) settle(id) },
138
- // test/debug introspection only
139
- size: () => cards.size,
140
- fired: (id) => !!cards.get(id)?.fired,
141
- }
142
- }
1
+ export const DEFAULT_MIN_TURN_MS=3e4;export const DEFAULT_PERM_GRACE_MS=2e4;export const SUMMARY_MAX=200;export function isUserFacingLane(e){return!!e&&!e.spawnedBy&&!e.flowTaskKey}export function clipSummary(e,r=200){if(null==e)return"";const t=String(e).replace(/\s+/g," ").trim();return t.length<=r?t:t.slice(0,r).replace(/\s+\S*$/,"").trimEnd()+"…"}export function shouldNotifyTurnDone({entry:e,subtype:r,startedAt:t,now:n,minTurnMs:o=3e4}){return!!shouldRecordTurnDone({entry:e,subtype:r,startedAt:t,now:n})&&n-t>=o}export function shouldRecordTurnDone({entry:e,subtype:r,startedAt:t,now:n}){return!!isUserFacingLane(e)&&"aborted"!==r&&!!t&&Number.isFinite(n)}export function permissionSummary(e){const r=e||{},t=Array.isArray(r.questions)?r.questions.map(e=>e?.question).filter(Boolean):[];if(t.length)return clipSummary(t.join(" / "));if("plan"===r.risk)return clipSummary(r.plan?`Plan ready: ${r.plan}`:"A plan is ready for review");const n=r.input,o=n&&(n.command||n.file_path||n.path||n.pattern||n.url||n.query||n.description),i=r.toolName||"A tool";return clipSummary(o?`${i} ${o}`:i)}export function createPermNotifier({graceMs:e=2e4,onArm:r=()=>{},onFire:t=()=>{},onRetract:n=()=>{},onResolve:o=()=>{},setTimer:i=setTimeout,clearTimer:a=clearTimeout}={}){const s=new Map,u=(e,r,t)=>{try{e(r,t)}catch{}},c=e=>{const r=s.get(e);if(r){if(r.timer)try{a(r.timer)}catch{}s.delete(e),r.fired&&u(n,e,r.payload),u(o,e,r.payload)}};return{arm(n,o){if(!n||s.has(n))return;const a={timer:null,fired:!1,payload:o};u(r,n,o),a.timer=i(()=>{a.timer=null,a.fired=!0,u(t,n,o)},e);try{a.timer?.unref?.()}catch{}s.set(n,a)},resolve:c,clearAll(){for(const e of[...s.keys()])c(e)},size:()=>s.size,fired:e=>!!s.get(e)?.fired}}
@@ -1,67 +1 @@
1
- // Host-local picker visibility for installed coding agents.
2
- //
3
- // Installation remains detection truth: hiding a runtime does not uninstall it
4
- // or prevent an explicit bridge command from opening it. This preference only
5
- // controls which installed runtimes the web offers in human-facing terminal
6
- // pickers. The file is shared by the account supervisor and its room children,
7
- // so one dashboard toggle applies to every room served by this bridge.
8
- import fs from 'node:fs'
9
- import os from 'node:os'
10
- import path from 'node:path'
11
-
12
- export const PICKER_AGENT_COMMANDS = ['claude', 'codex', 'thinkpool']
13
-
14
- const root = () => process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair')
15
- const defaultFile = () => path.join(root(), 'agent-visibility.json')
16
- const normalize = (command) => String(command || '').toLowerCase() === 'hermes'
17
- ? 'thinkpool'
18
- : String(command || '').toLowerCase()
19
-
20
- export function loadHiddenAgentCommands({ file = defaultFile(), fsImpl = fs } = {}) {
21
- try {
22
- const saved = JSON.parse(fsImpl.readFileSync(file, 'utf8'))
23
- return new Set((Array.isArray(saved?.hidden) ? saved.hidden : [])
24
- .map(normalize)
25
- .filter((command) => PICKER_AGENT_COMMANDS.includes(command)))
26
- } catch {
27
- return new Set()
28
- }
29
- }
30
-
31
- export function agentVisibilityStates(commands, { hidden = loadHiddenAgentCommands() } = {}) {
32
- const found = new Set((Array.isArray(commands) ? commands : []).map(normalize))
33
- const states = PICKER_AGENT_COMMANDS
34
- .filter((command) => found.has(command))
35
- .map((cmd) => ({ cmd, enabled: !hidden.has(cmd) }))
36
- // A corrupt or hand-edited preference must not leave the "+" action with no
37
- // valid runtime. The first installed product runtime is the safe recovery.
38
- if (states.length > 0 && !states.some((agent) => agent.enabled)) states[0].enabled = true
39
- return states
40
- }
41
-
42
- export function setAgentPickerVisibility(command, enabled, {
43
- installed = [],
44
- file = defaultFile(),
45
- fsImpl = fs,
46
- } = {}) {
47
- const cmd = normalize(command)
48
- if (!PICKER_AGENT_COMMANDS.includes(cmd)) return { ok: false, error: 'unknown agent runtime' }
49
- const states = agentVisibilityStates(installed, { hidden: loadHiddenAgentCommands({ file, fsImpl }) })
50
- if (!states.some((agent) => agent.cmd === cmd)) return { ok: false, error: 'agent runtime is not installed' }
51
- if (!enabled && states.filter((agent) => agent.enabled).length <= 1) {
52
- return { ok: false, error: 'keep at least one agent in the terminal picker' }
53
- }
54
-
55
- const hidden = new Set(states.filter((agent) => !agent.enabled).map((agent) => agent.cmd))
56
- if (enabled) hidden.delete(cmd)
57
- else hidden.add(cmd)
58
-
59
- try {
60
- fsImpl.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
61
- fsImpl.writeFileSync(file, JSON.stringify({ hidden: [...hidden] }, null, 2), { mode: 0o600 })
62
- try { fsImpl.chmodSync(file, 0o600) } catch { /* best-effort hardening */ }
63
- } catch {
64
- return { ok: false, error: 'could not save agent visibility' }
65
- }
66
- return { ok: true, agents: agentVisibilityStates(installed, { hidden }) }
67
- }
1
+ import e from"node:fs";import n from"node:os";import t from"node:path";export const PICKER_AGENT_COMMANDS=["claude","codex","thinkpool"];const i=()=>t.join(process.env.TP_PAIR_ROOT||t.join(n.homedir(),".thinkpool-pair"),"agent-visibility.json"),r=e=>"hermes"===String(e||"").toLowerCase()?"thinkpool":String(e||"").toLowerCase();export function loadHiddenAgentCommands({file:n=i(),fsImpl:t=e}={}){try{const e=JSON.parse(t.readFileSync(n,"utf8"));return new Set((Array.isArray(e?.hidden)?e.hidden:[]).map(r).filter(e=>PICKER_AGENT_COMMANDS.includes(e)))}catch{return new Set}}export function agentVisibilityStates(e,{hidden:n=loadHiddenAgentCommands()}={}){const t=new Set((Array.isArray(e)?e:[]).map(r)),i=PICKER_AGENT_COMMANDS.filter(e=>t.has(e)).map(e=>({cmd:e,enabled:!n.has(e)}));return i.length>0&&!i.some(e=>e.enabled)&&(i[0].enabled=!0),i}export function setAgentPickerVisibility(n,o,{installed:d=[],file:a=i(),fsImpl:s=e}={}){const l=r(n);if(!PICKER_AGENT_COMMANDS.includes(l))return{ok:!1,error:"unknown agent runtime"};const m=agentVisibilityStates(d,{hidden:loadHiddenAgentCommands({file:a,fsImpl:s})});if(!m.some(e=>e.cmd===l))return{ok:!1,error:"agent runtime is not installed"};if(!o&&m.filter(e=>e.enabled).length<=1)return{ok:!1,error:"keep at least one agent in the terminal picker"};const c=new Set(m.filter(e=>!e.enabled).map(e=>e.cmd));o?c.delete(l):c.add(l);try{s.mkdirSync(t.dirname(a),{recursive:!0,mode:448}),s.writeFileSync(a,JSON.stringify({hidden:[...c]},null,2),{mode:384});try{s.chmodSync(a,384)}catch{}}catch{return{ok:!1,error:"could not save agent visibility"}}return{ok:!0,agents:agentVisibilityStates(d,{hidden:c})}}
package/auth-store.mjs CHANGED
@@ -1,120 +1 @@
1
- /* auth-store.mjs — account-mode credentials + per-session directory map for the
2
- ThinkPool bridge. Files live under ~/.thinkpool-pair/ (chmod 600 for the token).
3
- - auth.json: the linked account's Supabase refresh token (rotated on each use).
4
- - dirs.json: per-MACHINE map of room code → absolute working directory, so one
5
- account bridge can run each session's terminals in its own repo.
6
- Phase 1 of the account-bridge spec (docs/specs/2026-06-16-account-bridge.md). */
7
- import os from 'node:os'
8
- import fs from 'node:fs'
9
- import path from 'node:path'
10
-
11
- const DIR = path.join(os.homedir(), '.thinkpool-pair')
12
- const AUTH = path.join(DIR, 'auth.json')
13
- const DIRS = path.join(DIR, 'dirs.json')
14
- // served.json: per-room memory of the last real (non-home) directory a room was
15
- // auto-served from. Distinct from dirs.json (explicit binds): this is learned, so a
16
- // supervisor relaunched from $HOME still resumes each room in the repo it last ran in
17
- // instead of falling back to the home folder (FVIHV1DE, 2026-06-30).
18
- const SERVED = path.join(DIR, 'served.json')
19
-
20
- function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
21
-
22
- function writeAuthPayload(payload) {
23
- ensureDir()
24
- // ATOMIC write (2026-07-08): a crash/kill/kickstart mid-write must never leave a
25
- // truncated auth.json — an unparseable token file reads as "not linked" and bricks the
26
- // saved login (three bridge outages in two days, one from a restart landing mid-rotation).
27
- // Write a sibling temp file, fsync it to disk, then rename OVER auth.json — rename is
28
- // atomic on one filesystem, so any concurrent reader sees the whole old file or the whole
29
- // new one, never a half-written token. This does NOT undo Supabase's server-side rotation
30
- // (that invalidates the old token the instant refreshSession returns) — the frequency of
31
- // rotation is cut by F1/F3 in account.mjs; this guarantees the file itself is never torn.
32
- const tmp = `${AUTH}.tmp.${process.pid}.${Date.now()}`
33
- try {
34
- const fd = fs.openSync(tmp, 'w', 0o600)
35
- try { fs.writeSync(fd, JSON.stringify(payload)); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
36
- fs.renameSync(tmp, AUTH)
37
- } catch {
38
- try { fs.rmSync(tmp, { force: true }) } catch { /* noop */ }
39
- }
40
- }
41
-
42
- export function saveAuth(session) {
43
- if (!session?.refresh_token) return
44
- writeAuthPayload({
45
- refresh_token: session.refresh_token,
46
- access_token: session.access_token || null,
47
- // Unix SECONDS. Lets the bridge adopt the saved access token WITHOUT a refresh while
48
- // it's still comfortably valid (account.mjs F1) — every skipped rotation is one fewer
49
- // race window (and one fewer chance to be killed mid-rotation and brick the login).
50
- expires_at: session.expires_at || null,
51
- email: session.user?.email || session.email || null,
52
- savedAt: Date.now(),
53
- })
54
- }
55
- export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
56
- export function markAuthReconnectRequired(errorCode = null) {
57
- const current = loadAuth()
58
- if (!current?.refresh_token) return false
59
- writeAuthPayload({
60
- ...current,
61
- reconnect_required: true,
62
- reconnect_error_code: errorCode || null,
63
- reconnect_required_at: Date.now(),
64
- })
65
- return true
66
- }
67
- export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
68
-
69
- export function loadDirs() { try { return JSON.parse(fs.readFileSync(DIRS, 'utf8')) } catch { return {} } }
70
- export function bindDir(room, dir) {
71
- const d = loadDirs(); d[room] = dir
72
- ensureDir()
73
- try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
74
- return d
75
- }
76
- export function unbindDir(room) {
77
- const d = loadDirs(); delete d[room]
78
- try { fs.writeFileSync(DIRS, JSON.stringify(d, null, 2)) } catch { /* noop */ }
79
- return d
80
- }
81
-
82
- // Learned per-room serve directory (see SERVED note above). loadServed feeds the
83
- // dir-resolution precedence; rememberServed records the dir a room was just served
84
- // from so a later relaunch-from-home resumes it there rather than in $HOME.
85
- export function loadServed() { try { return JSON.parse(fs.readFileSync(SERVED, 'utf8')) } catch { return {} } }
86
- export function rememberServed(room, dir) {
87
- const d = loadServed(); if (!room || !dir || d[room] === dir) return d
88
- d[room] = dir
89
- ensureDir()
90
- try { fs.writeFileSync(SERVED, JSON.stringify(d, null, 2)) } catch { /* noop */ }
91
- return d
92
- }
93
-
94
- // Account-wide DEFAULT serve directory: new/unbound rooms auto-serve HERE instead of
95
- // being refused from $HOME. Set once (CLI `set-default-dir <dir>`, or the web attach
96
- // card). One dir for the common "I mostly work in one repo" case; per-room binds still
97
- // override it. Stored as a plain path in ~/.thinkpool-pair/default-dir.
98
- const DEFAULTDIR = path.join(DIR, 'default-dir')
99
- export function loadDefaultDir() { try { const d = fs.readFileSync(DEFAULTDIR, 'utf8').trim(); return d || null } catch { return null } }
100
- export function saveDefaultDir(dir) { ensureDir(); try { fs.writeFileSync(DEFAULTDIR, String(dir || '')) } catch { /* noop */ } return dir }
101
- export function clearDefaultDir() { try { fs.unlinkSync(DEFAULTDIR) } catch { /* noop */ } }
102
-
103
- // Zero-config default: Claude Code tracks every project directory you've worked in
104
- // (~/.claude.json `projects`, with per-project session history under
105
- // ~/.claude/projects/<path-with-slashes-as-dashes>). The MOST-RECENTLY-used real project
106
- // dir is where you actually code — a great default for a new/unbound room, no setup. Skips
107
- // $HOME, tmp, and worktree paths; ranks by the session-dir mtime (encode path forward only,
108
- // never decode — the dash encoding is lossy for paths containing '-').
109
- export function claudeRecentProjectDir() {
110
- try {
111
- const home = os.homedir()
112
- const conf = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))
113
- const projRoot = path.join(home, '.claude', 'projects')
114
- const best = Object.keys(conf.projects || {})
115
- .filter((p) => p && p !== home && !p.startsWith('/private/tmp') && !p.startsWith('/tmp') && !p.includes('/.claude/') && fs.existsSync(p))
116
- .map((p) => { let mt = 0; try { mt = fs.statSync(path.join(projRoot, p.replace(/\//g, '-'))).mtimeMs } catch { /* no session dir */ } return { p, mt } })
117
- .sort((a, b) => b.mt - a.mt)[0]
118
- return best?.p || null
119
- } catch { return null }
120
- }
1
+ import t from"node:os";import e from"node:fs";import r from"node:path";const n=r.join(t.homedir(),".thinkpool-pair"),c=r.join(n,"auth.json"),o=r.join(n,"dirs.json"),i=r.join(n,"served.json");function u(){try{e.mkdirSync(n,{recursive:!0})}catch{}}function a(t){u();const r=`${c}.tmp.${process.pid}.${Date.now()}`;try{const n=e.openSync(r,"w",384);try{e.writeSync(n,JSON.stringify(t)),e.fsyncSync(n)}finally{e.closeSync(n)}e.renameSync(r,c)}catch{try{e.rmSync(r,{force:!0})}catch{}}}export function saveAuth(t){t?.refresh_token&&a({refresh_token:t.refresh_token,access_token:t.access_token||null,expires_at:t.expires_at||null,email:t.user?.email||t.email||null,savedAt:Date.now()})}export function loadAuth(){try{return JSON.parse(e.readFileSync(c,"utf8"))}catch{return null}}export function markAuthReconnectRequired(t=null){const e=loadAuth();return!!e?.refresh_token&&(a({...e,reconnect_required:!0,reconnect_error_code:t||null,reconnect_required_at:Date.now()}),!0)}export function clearAuth(){try{e.unlinkSync(c)}catch{}}export function loadDirs(){try{return JSON.parse(e.readFileSync(o,"utf8"))}catch{return{}}}export function bindDir(t,r){const n=loadDirs();n[t]=r,u();try{e.writeFileSync(o,JSON.stringify(n,null,2))}catch{}return n}export function unbindDir(t){const r=loadDirs();delete r[t];try{e.writeFileSync(o,JSON.stringify(r,null,2))}catch{}return r}export function loadServed(){try{return JSON.parse(e.readFileSync(i,"utf8"))}catch{return{}}}export function rememberServed(t,r){const n=loadServed();if(!t||!r||n[t]===r)return n;n[t]=r,u();try{e.writeFileSync(i,JSON.stringify(n,null,2))}catch{}return n}const l=r.join(n,"default-dir");export function loadDefaultDir(){try{return e.readFileSync(l,"utf8").trim()||null}catch{return null}}export function saveDefaultDir(t){u();try{e.writeFileSync(l,String(t||""))}catch{}return t}export function clearDefaultDir(){try{e.unlinkSync(l)}catch{}}export function claudeRecentProjectDir(){try{const n=t.homedir(),c=JSON.parse(e.readFileSync(r.join(n,".claude.json"),"utf8")),o=r.join(n,".claude","projects"),i=Object.keys(c.projects||{}).filter(t=>t&&t!==n&&!t.startsWith("/private/tmp")&&!t.startsWith("/tmp")&&!t.includes("/.claude/")&&e.existsSync(t)).map(t=>{let n=0;try{n=e.statSync(r.join(o,t.replace(/\//g,"-"))).mtimeMs}catch{}return{p:t,mt:n}}).sort((t,e)=>e.mt-t.mt)[0];return i?.p||null}catch{return null}}