skyborne 0.1.0__py3-none-any.whl

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 (55) hide show
  1. skyborne/__init__.py +3 -0
  2. skyborne/__main__.py +174 -0
  3. skyborne/approvals.py +370 -0
  4. skyborne/config.py +71 -0
  5. skyborne/describe.py +80 -0
  6. skyborne/doctor.py +146 -0
  7. skyborne/forward.py +70 -0
  8. skyborne/importer.py +251 -0
  9. skyborne/install.py +220 -0
  10. skyborne/record.py +249 -0
  11. skyborne/reducer.py +702 -0
  12. skyborne/scrub.py +240 -0
  13. skyborne/server.py +993 -0
  14. skyborne/store.py +358 -0
  15. skyborne/transcripts.py +209 -0
  16. skyborne/web/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  17. skyborne/web/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
  18. skyborne/web/assets/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
  19. skyborne/web/assets/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  20. skyborne/web/assets/fonts/martian-mono-latin-ext-wght-normal.woff2 +0 -0
  21. skyborne/web/assets/fonts/martian-mono-latin-wght-normal.woff2 +0 -0
  22. skyborne/web/assets/fonts/unbounded-latin-ext-wght-normal.woff2 +0 -0
  23. skyborne/web/assets/fonts/unbounded-latin-wght-normal.woff2 +0 -0
  24. skyborne/web/assets/icons/apple-touch-icon.png +0 -0
  25. skyborne/web/assets/icons/icon-32.png +0 -0
  26. skyborne/web/assets/icons/icon.svg +15 -0
  27. skyborne/web/assets/licenses/Inter-OFL.txt +93 -0
  28. skyborne/web/assets/licenses/JetBrainsMono-OFL.txt +93 -0
  29. skyborne/web/assets/licenses/MartianMono-OFL.txt +93 -0
  30. skyborne/web/assets/licenses/Unbounded-OFL.txt +93 -0
  31. skyborne/web/assets/licenses/three.js-LICENSE.txt +21 -0
  32. skyborne/web/assets/three/addons/controls/OrbitControls.js +1523 -0
  33. skyborne/web/assets/three/addons/environments/RoomEnvironment.js +144 -0
  34. skyborne/web/assets/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  35. skyborne/web/assets/three/addons/postprocessing/EffectComposer.js +231 -0
  36. skyborne/web/assets/three/addons/postprocessing/MaskPass.js +104 -0
  37. skyborne/web/assets/three/addons/postprocessing/OutputPass.js +97 -0
  38. skyborne/web/assets/three/addons/postprocessing/Pass.js +95 -0
  39. skyborne/web/assets/three/addons/postprocessing/RenderPass.js +99 -0
  40. skyborne/web/assets/three/addons/postprocessing/ShaderPass.js +77 -0
  41. skyborne/web/assets/three/addons/postprocessing/UnrealBloomPass.js +415 -0
  42. skyborne/web/assets/three/addons/renderers/CSS2DRenderer.js +238 -0
  43. skyborne/web/assets/three/addons/shaders/CopyShader.js +45 -0
  44. skyborne/web/assets/three/addons/shaders/LuminosityHighPassShader.js +64 -0
  45. skyborne/web/assets/three/addons/shaders/OutputShader.js +85 -0
  46. skyborne/web/assets/three/addons/utils/BufferGeometryUtils.js +1373 -0
  47. skyborne/web/assets/three/three.module.min.js +6 -0
  48. skyborne/web/index.html +5040 -0
  49. skyborne-0.1.0.dist-info/METADATA +296 -0
  50. skyborne-0.1.0.dist-info/RECORD +55 -0
  51. skyborne-0.1.0.dist-info/WHEEL +5 -0
  52. skyborne-0.1.0.dist-info/entry_points.txt +2 -0
  53. skyborne-0.1.0.dist-info/licenses/LICENSE +202 -0
  54. skyborne-0.1.0.dist-info/licenses/NOTICE +31 -0
  55. skyborne-0.1.0.dist-info/top_level.txt +1 -0
skyborne/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Skyborne: a local monitor for Claude Code."""
2
+
3
+ __version__ = '0.1.0'
skyborne/__main__.py ADDED
@@ -0,0 +1,174 @@
1
+ """The `skyborne` command.
2
+
3
+ skyborne [--port 7317] [--no-open] start the server and open the city (Ctrl+C stops it)
4
+ skyborne install [--port N] install the Claude Code plugin; asks before adding the status line
5
+ skyborne uninstall remove the plugin and put the status line back as it was
6
+ skyborne doctor [--port N] check the setup and explain any problem
7
+ skyborne import [--days 7] bring in past sessions from Claude Code's transcripts
8
+ skyborne record <session> --out F export one session, scrubbed, for the page to play back
9
+ """
10
+ import argparse
11
+ import sys
12
+
13
+ DEFAULT_PORT = 7317 # config.DEFAULT_PORT, repeated so the fast commands below import nothing else
14
+
15
+
16
+ def _ask(question: str, default: bool = False) -> bool:
17
+ try:
18
+ answer = input(question + (' [Y/n] ' if default else ' [y/N] ')).strip().lower()
19
+ except EOFError:
20
+ return default
21
+ return default if not answer else answer in ('y', 'yes')
22
+
23
+
24
+ def _import(days, db_path=None):
25
+ from . import importer
26
+ result = importer.run(days, db_path=db_path)
27
+ s = result['summary']
28
+ print(f"Imported {s.get('imported', 0)} session{'s' if s.get('imported', 0) != 1 else ''} "
29
+ f"({s.get('events', 0):,} events, {s.get('tokens', 0):,} tokens).")
30
+ skipped = [f'{n} {why}' for why in ('recorded live', 'imported before', 'empty', 'unreadable') if (n := s.get(why))]
31
+ if skipped:
32
+ print('Skipped: ' + ', '.join(skipped) + '.')
33
+ return 0
34
+
35
+
36
+ def _install(args):
37
+ from . import install
38
+ try:
39
+ target = install.install_plugin(args.port)
40
+ except install.InstallError as e:
41
+ print(e)
42
+ return 1
43
+ print(f'Installed the Claude Code plugin in {target}.')
44
+ print('New Claude Code sessions send their events to Skyborne. Sessions already open are not affected.')
45
+ if install._load_state().get('statusline'):
46
+ print('The status line is already set up.')
47
+ return 0
48
+ want = args.statusline if args.statusline is not None else _ask(
49
+ "Also set Claude Code's status line to Skyborne's? It adds cost, context and rate limits. "
50
+ 'Your settings.json is backed up first, only its statusLine key changes, and uninstall puts it back.')
51
+ if want:
52
+ try:
53
+ previous = install.install_statusline(args.port)
54
+ except install.InstallError as e:
55
+ print(e)
56
+ return 1
57
+ print('Status line installed.' + (' Your earlier status line still shows: Skyborne runs it and prints its output.' if previous else ''))
58
+ else:
59
+ print('Status line left as it was.')
60
+ return 0
61
+
62
+
63
+ def _uninstall(args):
64
+ from . import install
65
+ try:
66
+ print(install.uninstall_statusline())
67
+ except install.InstallError as e:
68
+ print(e)
69
+ return 1
70
+ print(install.uninstall_plugin())
71
+ return 0
72
+
73
+
74
+ def _run(args):
75
+ import logging
76
+ import webbrowser
77
+ from . import doctor, server
78
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S')
79
+ try:
80
+ app = server.App(args.port)
81
+ except OSError:
82
+ h = doctor.health(args.port)
83
+ if h and h.get('name') == 'skyborne':
84
+ print(f'Skyborne is already running at http://127.0.0.1:{args.port}/')
85
+ else:
86
+ print(f'Port {args.port} is taken by another program. Try `skyborne --port <other>` '
87
+ f'(and `skyborne install --port <other>` so the plugin sends there).')
88
+ return 1
89
+ _first_run(app)
90
+ app.start()
91
+ url = f'http://127.0.0.1:{app.port}/'
92
+ print(f'Skyborne is running at {url} (Ctrl+C to stop). Database: {app.store.path}', flush=True)
93
+ if not args.no_open:
94
+ webbrowser.open(url)
95
+ try:
96
+ while True:
97
+ import time
98
+ time.sleep(1)
99
+ except KeyboardInterrupt:
100
+ print('Stopping.')
101
+ finally:
102
+ app.stop()
103
+ return 0
104
+
105
+
106
+ def _first_run(app):
107
+ """The first time Skyborne starts, offer to bring in the last week of sessions."""
108
+ import time
109
+ from . import config
110
+ state = config.load_state()
111
+ if 'import_asked' in state:
112
+ return
113
+ if not sys.stdin.isatty():
114
+ print('Tip: `skyborne import --days 7` brings in your Claude Code sessions from the last week.')
115
+ return
116
+ yes = _ask('Import your Claude Code sessions from the last 7 days so they show in the city?', default=True)
117
+ if yes:
118
+ _import(7, db_path=app.store.path)
119
+ config.save_state({**state, 'import_asked': int(time.time()), 'imported': yes})
120
+
121
+
122
+ def main(argv=None):
123
+ argv = sys.argv[1:] if argv is None else argv
124
+ p = argparse.ArgumentParser(prog='skyborne', description='A local monitor for Claude Code.')
125
+ p.add_argument('--port', type=int, default=DEFAULT_PORT, help='port to listen on (default 7317)')
126
+ p.add_argument('--no-open', action='store_true', help="don't open the city in a browser")
127
+ p.add_argument('--verbose', action='store_true', help='log every event (name and short session id only)')
128
+ sub = p.add_subparsers(dest='command')
129
+ i = sub.add_parser('install', help='install the Claude Code plugin')
130
+ i.add_argument('--port', type=int, default=DEFAULT_PORT)
131
+ g = i.add_mutually_exclusive_group()
132
+ g.add_argument('--statusline', dest='statusline', action='store_true', default=None, help='also install the status line, without asking')
133
+ g.add_argument('--no-statusline', dest='statusline', action='store_false', help='leave the status line alone, without asking')
134
+ sub.add_parser('uninstall', help='remove the plugin and restore the status line')
135
+ d = sub.add_parser('doctor', help='check the setup')
136
+ d.add_argument('--port', type=int, default=DEFAULT_PORT)
137
+ m = sub.add_parser('import', help="bring in past sessions from Claude Code's transcripts")
138
+ m.add_argument('--days', type=float, default=7, help='how far back to look (default 7)')
139
+ r = sub.add_parser('record', help='export one session, scrubbed, for the page to play back')
140
+ r.add_argument('session', nargs='?', default='', help='a session id, or the start of one')
141
+ r.add_argument('--out', default='', help='the file to write, e.g. demo.json')
142
+ r.add_argument('--stand-ins', action='store_true', help='also replace prompts, replies and file contents with stand-ins')
143
+ for name in ('statusline', 'hook'): # run by Claude Code, not by people
144
+ s = sub.add_parser(name)
145
+ s.add_argument('--port', type=int, default=DEFAULT_PORT)
146
+
147
+ if argv[:1] == ['hook']: # must never print or fail, even on bad arguments
148
+ from .forward import hook
149
+ try:
150
+ port = int(argv[argv.index('--port') + 1]) if '--port' in argv else DEFAULT_PORT
151
+ except (ValueError, IndexError):
152
+ port = DEFAULT_PORT
153
+ return hook(port)
154
+ args = p.parse_args(argv)
155
+ if args.command == 'statusline':
156
+ from .forward import statusline
157
+ return statusline(args.port)
158
+ if args.command == 'install':
159
+ return _install(args)
160
+ if args.command == 'uninstall':
161
+ return _uninstall(args)
162
+ if args.command == 'doctor':
163
+ from .doctor import run
164
+ return run(args.port)
165
+ if args.command == 'import':
166
+ return _import(args.days)
167
+ if args.command == 'record':
168
+ from .record import main as record
169
+ return record(args.session, args.out, args.stand_ins)
170
+ return _run(args)
171
+
172
+
173
+ if __name__ == '__main__':
174
+ sys.exit(main())
skyborne/approvals.py ADDED
@@ -0,0 +1,370 @@
1
+ """Permission requests the page can answer ("asks"), and the log of how each one ended.
2
+
3
+ Claude Code's PermissionRequest hook posts to `/permission` and the server holds that request open while
4
+ the person can answer in the terminal or in the city page: whoever answers first wins (docs/FINDINGS.md).
5
+ An ask ends when:
6
+ - the page answers: `decide()` hands the answer to the held request, which writes it back to the hook;
7
+ - Claude Code hangs up the hook (a "No" typed in the terminal, or the session closing);
8
+ - the reducer shows the request is over (`sync()`): its call ran or failed (a "Yes" in the terminal, seen
9
+ only once the tool finishes), was refused (a "No", from the transcript), or the session ended, the
10
+ lead moved on or the helper stopped;
11
+ - the server stops.
12
+ When the hold reaches its deadline the hook gets no answer, the dialog stays in the terminal, and the
13
+ card says "Answer in the terminal" until the reducer shows the request is over.
14
+
15
+ Skyborne never answers by itself: only `decide()` writes an answer, and only the page's
16
+ `POST /api/answer` calls it. AskUserQuestion and ExitPlanMode need more than yes or no, so they are
17
+ never held or answered here: their cards only say to answer in the terminal.
18
+
19
+ An answer from the page is only shown as given once Claude Code confirms it: it writes a
20
+ `hook_permission_decision` line in the transcript (the helper's own, for a helper) when it applies a
21
+ hook's answer, before the call's result. Until then the card says the answer was sent. A result without
22
+ that line means the terminal had answered first (a "Yes" there doesn't stop the hook, so the page's
23
+ answer still reaches it, too late): the card says so and closes. Every ask leaves one row in the
24
+ `decisions` table, corrected by what the transcript showed.
25
+ """
26
+ import json
27
+ import logging
28
+ import os
29
+ import secrets
30
+ import threading
31
+ import time
32
+
33
+ from .reducer import LEAD, canonical
34
+ from .transcripts import READ_LIMIT, facts_from_line
35
+
36
+ log = logging.getLogger('skyborne')
37
+
38
+ TERMINAL_ONLY = ('AskUserQuestion', 'ExitPlanMode')
39
+ # a helper's dialog can outlive the lead's next prompt or stop, so only these end a helper's ask
40
+ HELPER_OVER = ('ran', 'failed', 'refused', 'denied', 'ended', 'stopped')
41
+ CONFIRM_WITHIN_MS = 10 * 60 * 1000 # an answer sent this long ago with no word from the transcript is let go
42
+ VERDICT_GRACE_S = 3 # after the call is over, how long the transcript may take to say who answered
43
+ TERMINAL_HOW = ('ran', 'failed', 'refused', 'hangup') # endings that mean the terminal answered
44
+ ANSWERS = {
45
+ 'allow': {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'allow'}}},
46
+ 'deny': {'hookSpecificOutput': {'hookEventName': 'PermissionRequest',
47
+ 'decision': {'behavior': 'deny', 'message': 'Denied from Skyborne'}}},
48
+ }
49
+ # how an ask ended (the reducer's reasons, plus the server's own) -> (decision, answered in)
50
+ OUTCOMES = {
51
+ 'ran': ('allow', 'terminal'), 'failed': ('allow', 'terminal'), 'refused': ('deny', 'terminal'),
52
+ 'denied': ('deny', 'nowhere'), 'hangup': ('deny', 'terminal'),
53
+ 'prompt': ('unknown', 'terminal'), 'idle': ('unknown', 'terminal'), 'stopped': ('unknown', 'terminal'),
54
+ 'ended': ('none', 'nowhere'), 'server_stop': ('none', 'nowhere'), 'error': ('unknown', 'terminal'),
55
+ }
56
+
57
+
58
+ def over(agent, state):
59
+ """Whether the reducer's state for a request means it's over for its ask. A helper's dialog can outlive
60
+ the lead's next prompt or stop, so only the helper's own ending (HELPER_OVER) counts for it."""
61
+ return state not in ('open', 'unseen') and (agent == LEAD or state in HELPER_OVER)
62
+
63
+
64
+ def now_ms():
65
+ return int(time.time() * 1000)
66
+
67
+
68
+ def _str(v):
69
+ return v if isinstance(v, str) else ''
70
+
71
+
72
+ class Ask:
73
+ def __init__(self, payload, received_at):
74
+ self.id = secrets.token_urlsafe(12)
75
+ self.sid = payload['session_id']
76
+ self.agent = _str(payload.get('agent_id')) or LEAD
77
+ self.tool = payload['tool_name']
78
+ self.input = payload.get('tool_input')
79
+ self.key = canonical(self.input)
80
+ self.ts = received_at
81
+ self.suggestions = payload.get('permission_suggestions')
82
+ self.transcript = _str(payload.get('transcript_path'))
83
+ self.terminal_only = self.tool in TERMINAL_ONLY
84
+ self.state = 'terminal' if self.terminal_only else 'open' # open | terminal | decided | sent | closed
85
+ self.claimed = False # its event has reached the reducer, tagged with this ask's id
86
+ self.held = not self.terminal_only
87
+ self.until = None # ms when Skyborne stops holding it and leaves it to the terminal (held asks only)
88
+ self.timed_out = False
89
+ self.decision = None # the page's answer
90
+ self.call = '' # the tool call it belongs to, once the reducer has matched it
91
+ self.wake = threading.Event()
92
+ self.delivered = threading.Event()
93
+ self.delivered_ok = False
94
+ self.watch = None # [transcript file, offset where the request came in, unfinished line]
95
+ self.sent_at = None # monotonic time the page's answer reached the hook
96
+ self.over_at = None # monotonic time the reducer first said the call is over
97
+
98
+ def card(self):
99
+ return {'id': self.id, 'session': self.sid, 'agent': self.agent, 'tool': self.tool, 'input': self.input,
100
+ 'since': self.ts, 'until': self.until, 'state': self.state, 'terminalOnly': self.terminal_only}
101
+
102
+ def transcript_file(self):
103
+ """Where Claude Code writes what happens to this request: the session's transcript, or for a helper its
104
+ own, `<session>/subagents/agent-<id>.jsonl` next to it (docs/FINDINGS.md)."""
105
+ if not self.transcript.endswith('.jsonl'):
106
+ return None
107
+ if self.agent == LEAD:
108
+ return self.transcript
109
+ return os.path.join(self.transcript[:-len('.jsonl')], 'subagents', f'agent-{self.agent}.jsonl')
110
+
111
+ def row(self, decision, answered_in, how, confirmed=None):
112
+ at = now_ms()
113
+ return {'id': self.id, 'session_id': self.sid, 'agent_id': self.agent, 'tool_name': self.tool,
114
+ 'request': {'tool_input': self.input, 'permission_suggestions': self.suggestions},
115
+ 'asked_at': self.ts, 'answered_at': at, 'decision': decision, 'answered_in': answered_in, 'how': how,
116
+ 'timed_out': int(self.timed_out), 'waited_ms': max(0, at - self.ts), 'confirmed': confirmed}
117
+
118
+
119
+ class Approvals:
120
+ """`broadcast(event, data)` reaches every /events listener; `record(item)` queues a database write;
121
+ `state(sid, ts, agent, key, ask id)` asks the reducer where a request stands: (state, call id)."""
122
+
123
+ def __init__(self, broadcast, record, state):
124
+ self.broadcast, self.record, self.state = broadcast, record, state
125
+ self.lock = threading.Lock()
126
+ self.asks = {} # id -> Ask with a card on the page (open, terminal, decided or sent)
127
+ self.ended = {} # id -> (why a late answer is refused: 'terminal' | 'answered' | 'closed', monotonic time)
128
+ self.v = 0
129
+
130
+ # ---- what the page sees ----
131
+ def snapshot(self):
132
+ with self.lock:
133
+ return self._snapshot()
134
+
135
+ def _snapshot(self): # the caller holds the lock
136
+ self.v += 1
137
+ cards = [a.card() for a in sorted(self.asks.values(), key=lambda a: (a.ts, a.id)) if a.state in ('open', 'terminal', 'sent')]
138
+ return {'v': self.v, 'asks': cards}
139
+
140
+ def _changed(self, snap, answer=None):
141
+ if answer: # first, so a page can mark the card before the list drops it
142
+ self.broadcast('answer', answer)
143
+ self.broadcast('asks', snap)
144
+
145
+ def _close(self, ask, reason): # the caller holds the lock
146
+ ask.state = 'closed'
147
+ self.asks.pop(ask.id, None)
148
+ self.ended[ask.id] = (reason, time.monotonic())
149
+
150
+ # ---- the held request ----
151
+ def open(self, payload, received_at, hold_s=None):
152
+ """A new PermissionRequest, held for `hold_s` seconds at most; None when it can't be one (no session or tool)."""
153
+ if not (isinstance(payload, dict) and _str(payload.get('session_id')) and _str(payload.get('tool_name'))):
154
+ return None
155
+ ask = Ask(payload, received_at)
156
+ if ask.held and hold_s:
157
+ ask.until = received_at + int(hold_s * 1000)
158
+ # what Claude Code writes about this request (its hook decision, its result) can only come after it, and
159
+ # either may come before the page's answer is sent: the transcript is read from here
160
+ path = ask.transcript_file()
161
+ try:
162
+ offset = os.path.getsize(path) if path else 0
163
+ except OSError:
164
+ offset = 0 # not written yet (a helper's first lines): everything in it will be new
165
+ ask.watch = [path, offset, b''] if path else None
166
+ with self.lock:
167
+ self.asks[ask.id] = ask
168
+ snap = self._snapshot()
169
+ self._changed(snap)
170
+ return ask
171
+
172
+ def claim(self, sid, ts, payload):
173
+ """The writer is about to hand this PermissionRequest event to the reducer: the id of its ask, if any."""
174
+ agent, key = _str(payload.get('agent_id')) or LEAD, canonical(payload.get('tool_input'))
175
+ with self.lock:
176
+ for a in self.asks.values():
177
+ if not a.claimed and (a.sid, a.ts, a.agent, a.key) == (sid, ts, agent, key):
178
+ a.claimed = True
179
+ return a.id
180
+ return None
181
+
182
+ def expire(self, ask):
183
+ """The hold reached its deadline: the dialog stays in the terminal. False if an answer just came in."""
184
+ with self.lock:
185
+ if ask.state != 'open':
186
+ return False
187
+ ask.state, ask.held, ask.timed_out = 'terminal', False, True
188
+ snap = self._snapshot()
189
+ log.info('approval for %s in session %s: no answer from the page in time; left to the terminal', ask.tool, ask.sid[:8])
190
+ self._changed(snap)
191
+ return True
192
+
193
+ def hung_up(self, ask):
194
+ """Claude Code closed the hook: the person answered in the terminal (or the session closed). Forced, so a
195
+ page answer arriving at the same moment learns it didn't get through."""
196
+ self.release(ask, 'hangup', force=True)
197
+
198
+ def delivered(self, ask, ok):
199
+ """The held request wrote the page's answer to the hook (ok), or couldn't (Claude Code hung up first)."""
200
+ with self.lock:
201
+ if ask.state != 'decided':
202
+ return
203
+ ask.delivered_ok = ok
204
+ if ok:
205
+ ask.state, ask.sent_at = 'sent', time.monotonic()
206
+ snap = self._snapshot() if ok else None
207
+ if ok:
208
+ row = ask.row(ask.decision, 'skyborne', 'page')
209
+ self.record(('decision', row['answered_at'], row))
210
+ self._log(row)
211
+ self._changed(snap)
212
+ ask.delivered.set() # what came of it is read by sync(), on the flusher thread only
213
+ else:
214
+ self.release(ask, 'hangup', force=True) # sets `delivered` once the refusal's reason is recorded
215
+
216
+ # ---- the page's answer ----
217
+ def decide(self, ask_id, decision, wait=5.0):
218
+ """The page's answer. Returns (True, '') once it reached the hook, else (False, why): 'terminal' (the
219
+ terminal answered first), 'answered' (another page did) or 'closed' (the request is over)."""
220
+ if decision not in ANSWERS:
221
+ raise ValueError(decision)
222
+ with self.lock:
223
+ ask = self.asks.get(ask_id)
224
+ refused = self._refusal(ask_id, ask)
225
+ if refused:
226
+ return False, refused
227
+ state, call = self.state(ask.sid, ask.ts, ask.agent, ask.key, ask.id)
228
+ if over(ask.agent, state): # it's over; sync() just hasn't seen it yet
229
+ self.release(ask, state, call)
230
+ with self.lock:
231
+ return False, self._refusal(ask_id, None) or 'closed'
232
+ with self.lock:
233
+ refused = self._refusal(ask_id, ask)
234
+ if refused:
235
+ return False, refused
236
+ ask.state, ask.decision, ask.call = 'decided', decision, call or ask.call
237
+ ask.wake.set()
238
+ if not ask.delivered.wait(wait):
239
+ log.warning('approval for %s in session %s: the answer was not delivered in time', ask.tool, ask.sid[:8])
240
+ return False, 'closed'
241
+ if ask.delivered_ok:
242
+ return True, ''
243
+ with self.lock:
244
+ return False, self._refusal(ask_id, None) or 'closed'
245
+
246
+ def _refusal(self, ask_id, ask): # the caller holds the lock
247
+ """Why a page answer can't be taken now, or '' when it can."""
248
+ if ask is not None:
249
+ return '' if ask.state == 'open' else 'answered' if ask.state in ('decided', 'sent') else 'closed'
250
+ return self.ended.get(ask_id, ('closed', 0))[0]
251
+
252
+ # ---- endings seen elsewhere ----
253
+ def release(self, ask, how, call='', force=False):
254
+ """End an ask that the page didn't answer (or whose answer couldn't be delivered)."""
255
+ with self.lock:
256
+ if ask.state in ('closed', 'sent') or (ask.state == 'decided' and not force):
257
+ return
258
+ ask.call = call or ask.call
259
+ self._close(ask, 'terminal' if how in TERMINAL_HOW else 'closed')
260
+ snap = self._snapshot()
261
+ decision, answered_in = OUTCOMES.get(how, ('unknown', 'terminal'))
262
+ row = ask.row(decision, answered_in, how)
263
+ self.record(('decision', row['answered_at'], row))
264
+ self._log(row)
265
+ ask.wake.set()
266
+ ask.delivered.set() # a page answer still waiting in decide() learns it didn't get through
267
+ self._changed(snap)
268
+
269
+ def sync(self, sids=None):
270
+ """After the reducer took in new events: end the asks whose request is over, and settle the answers
271
+ sent from the page once the transcript says what came of them."""
272
+ with self.lock:
273
+ asks = [a for a in self.asks.values() if (a.state in ('open', 'terminal') and (sids is None or a.sid in sids))
274
+ or a.state == 'sent']
275
+ for a in asks:
276
+ if a.state == 'sent':
277
+ self._check_sent(a)
278
+ continue
279
+ state, call = self.state(a.sid, a.ts, a.agent, a.key, a.id)
280
+ if call:
281
+ a.call = call
282
+ if over(a.agent, state):
283
+ self.release(a, state, call)
284
+ self._forget_ended()
285
+
286
+ def _check_sent(self, ask):
287
+ """Read what Claude Code wrote since the page's answer went out, and settle the card when it says."""
288
+ if not ask.call:
289
+ ask.call = self.state(ask.sid, ask.ts, ask.agent, ask.key, ask.id)[1]
290
+ # read only once the call is known: a line read now couldn't be matched, and would be lost
291
+ for f in self._new_facts(ask) if ask.call else []:
292
+ if f.get('tool_use_id') != ask.call:
293
+ continue
294
+ if f['kind'] == 'hook_decision' and f['decision'] == ask.decision:
295
+ return self._settle(ask, True, f['decision'])
296
+ if f['kind'] == 'tool_result': # its result came with no hook decision of ours before it: answered elsewhere
297
+ return self._settle(ask, False, 'deny' if f.get('refused') else 'allow')
298
+ now = time.monotonic()
299
+ if ask.over_at is None and over(ask.agent, self.state(ask.sid, ask.ts, ask.agent, ask.key, ask.id)[0]):
300
+ ask.over_at = now
301
+ if (ask.over_at is not None and now - ask.over_at > VERDICT_GRACE_S) or now - ask.sent_at > CONFIRM_WITHIN_MS / 1000:
302
+ self._settle(ask, None, None) # the transcript never said (a helper working elsewhere, a lost file)
303
+
304
+ @staticmethod
305
+ def _new_facts(ask):
306
+ if not ask.watch:
307
+ return []
308
+ path, offset, partial = ask.watch
309
+ try:
310
+ with open(path, 'rb') as fh:
311
+ fh.seek(offset)
312
+ data = fh.read(READ_LIMIT)
313
+ except OSError:
314
+ return []
315
+ ask.watch[1] = offset + len(data)
316
+ lines = (partial + data).split(b'\n')
317
+ ask.watch[2] = lines.pop() # an unfinished last line waits for the rest
318
+ facts = []
319
+ for line in lines:
320
+ try:
321
+ facts += facts_from_line(json.loads(line), ask.sid, ask.agent, now_ms())
322
+ except (ValueError, RecursionError):
323
+ continue
324
+ return facts
325
+
326
+ def _settle(self, ask, applied, decision):
327
+ """The outcome of an answer sent from the page: applied (True), the terminal had answered (False), or
328
+ unknown (None)."""
329
+ with self.lock:
330
+ if ask.state != 'sent':
331
+ return
332
+ self._close(ask, 'answered' if applied is not False else 'terminal')
333
+ snap = self._snapshot()
334
+ if applied is True:
335
+ self.record(('decision_confirm', 0, (ask.id, True, decision, 'skyborne')))
336
+ elif applied is False:
337
+ self.record(('decision_confirm', 0, (ask.id, False, decision, 'terminal')))
338
+ log.info('approval for %s in session %s: the terminal had answered first', ask.tool, ask.sid[:8])
339
+ self._changed(snap, {'id': ask.id, 'session': ask.sid, 'agent': ask.agent, 'decision': ask.decision, 'applied': applied})
340
+
341
+ def forget_sessions(self, sids):
342
+ """Sessions the server stopped following (quiet for hours): their asks can't end any other way."""
343
+ with self.lock:
344
+ asks = [a for a in self.asks.values() if a.sid in sids]
345
+ for a in asks:
346
+ if a.state == 'sent':
347
+ self._settle(a, None, None)
348
+ elif a.state in ('open', 'terminal'):
349
+ self.release(a, 'ended')
350
+
351
+ def _forget_ended(self):
352
+ cutoff = time.monotonic() - CONFIRM_WITHIN_MS / 1000
353
+ with self.lock:
354
+ for k in [k for k, (_why, at) in self.ended.items() if at < cutoff]:
355
+ del self.ended[k]
356
+
357
+ def close_all(self):
358
+ """The server is stopping: every held request gets no answer (the dialogs stay in the terminal)."""
359
+ with self.lock:
360
+ asks = [a for a in self.asks.values() if a.state != 'closed']
361
+ for a in asks:
362
+ if a.state == 'sent':
363
+ self._settle(a, None, None)
364
+ elif a.state != 'decided': # one being written to its hook right now is left to finish
365
+ self.release(a, 'server_stop', force=True)
366
+
367
+ @staticmethod
368
+ def _log(row):
369
+ log.info('approval for %s in session %s: %s, answered in %s (%s) after %.1f s', row['tool_name'],
370
+ row['session_id'][:8], row['decision'], row['answered_in'], row['how'], row['waited_ms'] / 1000)
skyborne/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """Where Skyborne keeps its files, and the few settings it reads."""
2
+ import json
3
+ import os
4
+ import pathlib
5
+
6
+ DEFAULT_PORT = 7317
7
+ DEFAULT_RETENTION_DAYS = 30
8
+ DEFAULT_APPROVAL_TIMEOUT = 600 # seconds the PermissionRequest hook waits for an answer from the page
9
+ MIN_APPROVAL_TIMEOUT = 30
10
+ MAX_APPROVAL_TIMEOUT = 3600
11
+
12
+
13
+ def home() -> pathlib.Path:
14
+ """Skyborne's own folder: ~/.skyborne, or $SKYBORNE_HOME (tests use a temporary one)."""
15
+ return pathlib.Path(os.environ.get('SKYBORNE_HOME') or pathlib.Path.home() / '.skyborne')
16
+
17
+
18
+ def ensure_home() -> pathlib.Path:
19
+ """Create Skyborne's folder if needed, private to its owner: it holds prompts, tool output and settings backups."""
20
+ h = home()
21
+ h.mkdir(mode=0o700, parents=True, exist_ok=True)
22
+ return h
23
+
24
+
25
+ def claude_dir() -> pathlib.Path:
26
+ """Claude Code's configuration folder: ~/.claude, or $CLAUDE_CONFIG_DIR when it's set."""
27
+ return pathlib.Path(os.environ.get('CLAUDE_CONFIG_DIR') or pathlib.Path.home() / '.claude')
28
+
29
+
30
+ def db_path() -> pathlib.Path:
31
+ return home() / 'skyborne.db'
32
+
33
+
34
+ def load() -> dict:
35
+ """~/.skyborne/config.json, if there is one. Unknown keys are ignored."""
36
+ try:
37
+ data = json.loads((home() / 'config.json').read_text(encoding='utf-8'))
38
+ except (OSError, ValueError):
39
+ return {}
40
+ return data if isinstance(data, dict) else {}
41
+
42
+
43
+ def retention_days() -> int:
44
+ days = load().get('retention_days', DEFAULT_RETENTION_DAYS)
45
+ return days if isinstance(days, int) and not isinstance(days, bool) and days > 0 else DEFAULT_RETENTION_DAYS
46
+
47
+
48
+ def approval_timeout() -> int:
49
+ """`approval_timeout_seconds` from config.json: how long a permission request waits for the page (kept
50
+ within 30 to 3600 s, default 600). `skyborne install` writes it into the plugin, so a change needs a
51
+ re-install."""
52
+ v = load().get('approval_timeout_seconds', DEFAULT_APPROVAL_TIMEOUT)
53
+ if not isinstance(v, int) or isinstance(v, bool):
54
+ return DEFAULT_APPROVAL_TIMEOUT
55
+ return min(max(v, MIN_APPROVAL_TIMEOUT), MAX_APPROVAL_TIMEOUT)
56
+
57
+
58
+ def load_state() -> dict:
59
+ """~/.skyborne/state.json: what Skyborne remembers between runs (e.g. that it asked about importing)."""
60
+ try:
61
+ data = json.loads((home() / 'state.json').read_text(encoding='utf-8'))
62
+ except (OSError, ValueError):
63
+ return {}
64
+ return data if isinstance(data, dict) else {}
65
+
66
+
67
+ def save_state(state: dict):
68
+ path = ensure_home() / 'state.json'
69
+ tmp = path.with_suffix('.tmp')
70
+ tmp.write_text(json.dumps(state, indent=2) + '\n', encoding='utf-8')
71
+ os.replace(tmp, path)