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.
- skyborne/__init__.py +3 -0
- skyborne/__main__.py +174 -0
- skyborne/approvals.py +370 -0
- skyborne/config.py +71 -0
- skyborne/describe.py +80 -0
- skyborne/doctor.py +146 -0
- skyborne/forward.py +70 -0
- skyborne/importer.py +251 -0
- skyborne/install.py +220 -0
- skyborne/record.py +249 -0
- skyborne/reducer.py +702 -0
- skyborne/scrub.py +240 -0
- skyborne/server.py +993 -0
- skyborne/store.py +358 -0
- skyborne/transcripts.py +209 -0
- skyborne/web/assets/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/inter-latin-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/jetbrains-mono-latin-ext-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/martian-mono-latin-ext-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/martian-mono-latin-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/unbounded-latin-ext-wght-normal.woff2 +0 -0
- skyborne/web/assets/fonts/unbounded-latin-wght-normal.woff2 +0 -0
- skyborne/web/assets/icons/apple-touch-icon.png +0 -0
- skyborne/web/assets/icons/icon-32.png +0 -0
- skyborne/web/assets/icons/icon.svg +15 -0
- skyborne/web/assets/licenses/Inter-OFL.txt +93 -0
- skyborne/web/assets/licenses/JetBrainsMono-OFL.txt +93 -0
- skyborne/web/assets/licenses/MartianMono-OFL.txt +93 -0
- skyborne/web/assets/licenses/Unbounded-OFL.txt +93 -0
- skyborne/web/assets/licenses/three.js-LICENSE.txt +21 -0
- skyborne/web/assets/three/addons/controls/OrbitControls.js +1523 -0
- skyborne/web/assets/three/addons/environments/RoomEnvironment.js +144 -0
- skyborne/web/assets/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- skyborne/web/assets/three/addons/postprocessing/EffectComposer.js +231 -0
- skyborne/web/assets/three/addons/postprocessing/MaskPass.js +104 -0
- skyborne/web/assets/three/addons/postprocessing/OutputPass.js +97 -0
- skyborne/web/assets/three/addons/postprocessing/Pass.js +95 -0
- skyborne/web/assets/three/addons/postprocessing/RenderPass.js +99 -0
- skyborne/web/assets/three/addons/postprocessing/ShaderPass.js +77 -0
- skyborne/web/assets/three/addons/postprocessing/UnrealBloomPass.js +415 -0
- skyborne/web/assets/three/addons/renderers/CSS2DRenderer.js +238 -0
- skyborne/web/assets/three/addons/shaders/CopyShader.js +45 -0
- skyborne/web/assets/three/addons/shaders/LuminosityHighPassShader.js +64 -0
- skyborne/web/assets/three/addons/shaders/OutputShader.js +85 -0
- skyborne/web/assets/three/addons/utils/BufferGeometryUtils.js +1373 -0
- skyborne/web/assets/three/three.module.min.js +6 -0
- skyborne/web/index.html +5040 -0
- skyborne-0.1.0.dist-info/METADATA +296 -0
- skyborne-0.1.0.dist-info/RECORD +55 -0
- skyborne-0.1.0.dist-info/WHEEL +5 -0
- skyborne-0.1.0.dist-info/entry_points.txt +2 -0
- skyborne-0.1.0.dist-info/licenses/LICENSE +202 -0
- skyborne-0.1.0.dist-info/licenses/NOTICE +31 -0
- skyborne-0.1.0.dist-info/top_level.txt +1 -0
skyborne/__init__.py
ADDED
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)
|