@onepointfour-packs/flaredeck-agent 0.1.0 → 0.2.1
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.
- package/dist/agent.d.ts +16 -0
- package/dist/agent.js +38 -1
- package/dist/agent.js.map +1 -1
- package/dist/approval.js +39 -1
- package/dist/approval.js.map +1 -1
- package/dist/browser.d.ts +19 -0
- package/dist/browser.js +261 -0
- package/dist/browser.js.map +1 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +2 -1
- package/dist/config.js.map +1 -1
- package/dist/desperation.d.ts +24 -0
- package/dist/desperation.js +64 -0
- package/dist/desperation.js.map +1 -0
- package/dist/engine-antigravity.d.ts +7 -0
- package/dist/engine-antigravity.js +280 -32
- package/dist/engine-antigravity.js.map +1 -1
- package/dist/engine.d.ts +28 -0
- package/dist/engine.js.map +1 -1
- package/dist/env.d.ts +5 -1
- package/dist/env.js +19 -1
- package/dist/env.js.map +1 -1
- package/dist/environment.d.ts +87 -2
- package/dist/environment.js +213 -11
- package/dist/environment.js.map +1 -1
- package/dist/flaredeck-proxy.d.ts +47 -1
- package/dist/flaredeck-proxy.js +120 -3
- package/dist/flaredeck-proxy.js.map +1 -1
- package/dist/gitguard.d.ts +6 -0
- package/dist/gitguard.js +30 -1
- package/dist/gitguard.js.map +1 -1
- package/dist/github.d.ts +23 -1
- package/dist/github.js +105 -28
- package/dist/github.js.map +1 -1
- package/dist/journal.d.ts +23 -0
- package/dist/journal.js +142 -0
- package/dist/journal.js.map +1 -0
- package/dist/localmcp.d.ts +50 -0
- package/dist/localmcp.js +229 -0
- package/dist/localmcp.js.map +1 -0
- package/dist/main.js +13 -1
- package/dist/main.js.map +1 -1
- package/dist/permissions.d.ts +17 -0
- package/dist/permissions.js +38 -1
- package/dist/permissions.js.map +1 -1
- package/dist/runs.js +3 -2
- package/dist/runs.js.map +1 -1
- package/dist/sandbox.d.ts +34 -0
- package/dist/sandbox.js +122 -2
- package/dist/sandbox.js.map +1 -1
- package/dist/schedules.d.ts +1 -1
- package/dist/schedules.js +3 -2
- package/dist/schedules.js.map +1 -1
- package/dist/serve.js +461 -39
- package/dist/serve.js.map +1 -1
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +99 -0
- package/dist/setup.js.map +1 -0
- package/dist/stream.d.ts +1 -0
- package/dist/team.d.ts +1 -1
- package/dist/team.js +3 -2
- package/dist/team.js.map +1 -1
- package/dist/ui.d.ts +2 -0
- package/dist/ui.js +34 -39
- package/dist/ui.js.map +1 -1
- package/dist/updater-python.d.ts +19 -0
- package/dist/updater-python.js +149 -0
- package/dist/updater-python.js.map +1 -0
- package/dist/workspace.d.ts +2 -0
- package/dist/workspace.js +1 -1
- package/dist/workspace.js.map +1 -1
- package/dist/worktree.d.ts +39 -1
- package/dist/worktree.js +147 -4
- package/dist/worktree.js.map +1 -1
- package/package.json +3 -2
- package/python/sidecar.py +209 -7
package/python/sidecar.py
CHANGED
|
@@ -66,7 +66,19 @@ async def gate(data):
|
|
|
66
66
|
"args": args,
|
|
67
67
|
})
|
|
68
68
|
if not verdict.get("allow", False):
|
|
69
|
-
|
|
69
|
+
# Either key. The harness grew a second set of guards that answer with `reason` while
|
|
70
|
+
# this read only `message`, so every one of them reached the model as the bare words
|
|
71
|
+
# "Refused by the harness." — no which-checkout, no use-push_branch, no the-card-is-not-
|
|
72
|
+
# finished. An agent that is told no and not told why cannot correct; it argues, or goes
|
|
73
|
+
# looking. Reading both means a new guard cannot be silent by picking the other word.
|
|
74
|
+
why = verdict.get("reason") or verdict.get("message")
|
|
75
|
+
return HookResult(
|
|
76
|
+
allow=False,
|
|
77
|
+
message=str(why) if why else (
|
|
78
|
+
"The harness refused this and gave no reason, which is a bug in the harness. "
|
|
79
|
+
"Do not retry it unchanged; say on the card what you were trying to do."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
70
82
|
modified = verdict.get("modified_args")
|
|
71
83
|
if modified:
|
|
72
84
|
return HookResult(allow=True, modified_args=modified)
|
|
@@ -84,9 +96,178 @@ async def seen(data):
|
|
|
84
96
|
return None
|
|
85
97
|
|
|
86
98
|
|
|
99
|
+
# What a flush is worth saying on its own. Model text arrives in small pieces; one harness event
|
|
100
|
+
# per piece would be a pane of fragments, and one event for the whole turn — which is what
|
|
101
|
+
# `response.text()` gave — is a blank pane for minutes and then a wall.
|
|
102
|
+
FLUSH_CHARS = 280
|
|
103
|
+
|
|
104
|
+
# How long the model may say NOTHING before the turn is abandoned. It streams text, thinking and
|
|
105
|
+
# tool calls, so a silence this long is a stalled stream rather than a long thought: HR-0622 sat
|
|
106
|
+
# eleven minutes with an open connection to Google, zero CPU, and no chunk — and nothing noticed,
|
|
107
|
+
# because the harness only ever waited. An unattended agent cannot wait forever.
|
|
108
|
+
STALL_SECONDS = float(os.environ.get("FLAREDECK_AGENT_STALL_SECONDS", "300"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def stdin_lines() -> asyncio.StreamReader:
|
|
112
|
+
"""
|
|
113
|
+
stdin as an async stream.
|
|
114
|
+
|
|
115
|
+
Deliberately not `run_in_executor(sys.stdin.readline)`: that parks a thread in the default
|
|
116
|
+
executor, which `asyncio.run` waits for at shutdown — and since the harness holds the pipe open
|
|
117
|
+
for the life of the process, the read never returns and the sidecar hangs on exit instead of
|
|
118
|
+
dying. This also means ONE reader owns the fd, so the run message and any steer come off the
|
|
119
|
+
same stream and nothing is lost to buffering.
|
|
120
|
+
"""
|
|
121
|
+
loop = asyncio.get_running_loop()
|
|
122
|
+
reader = asyncio.StreamReader()
|
|
123
|
+
await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
|
|
124
|
+
return reader
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _steers(queue: "asyncio.Queue[str]", lines: asyncio.StreamReader) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Lines the harness sends while a turn is running. stdin was read once, for the run message, and
|
|
130
|
+
then never again, so anything typed into the pane had nowhere to go.
|
|
131
|
+
"""
|
|
132
|
+
while True:
|
|
133
|
+
raw = await lines.readline()
|
|
134
|
+
if not raw:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
msg = json.loads(raw.decode())
|
|
138
|
+
except Exception: # noqa: BLE001
|
|
139
|
+
continue
|
|
140
|
+
if msg.get("type") == "steer" and msg.get("text"):
|
|
141
|
+
await queue.put(str(msg["text"]))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _drain(response, said: list[str]) -> None:
|
|
145
|
+
"""
|
|
146
|
+
One turn's chunk stream, mapped onto harness events as it arrives.
|
|
147
|
+
|
|
148
|
+
`response.text()` waits for everything and returns a single string, which is why a Gemini turn
|
|
149
|
+
showed nothing at all until it was over. The same response exposes the semantic stream; this
|
|
150
|
+
reads that instead, so text, thinking and tool calls appear while they happen.
|
|
151
|
+
"""
|
|
152
|
+
buffer: list[str] = []
|
|
153
|
+
|
|
154
|
+
def flush() -> None:
|
|
155
|
+
joined = "".join(buffer).strip()
|
|
156
|
+
buffer.clear()
|
|
157
|
+
if joined:
|
|
158
|
+
said.append(joined)
|
|
159
|
+
emit({"type": "event", "kind": "text", "text": joined})
|
|
160
|
+
|
|
161
|
+
# `chunks` is a PROPERTY returning a fresh cursor, not a method. Calling it raises
|
|
162
|
+
# "'async_generator' object is not callable" — which a hand-written fake that defines it as a
|
|
163
|
+
# method will never show you. Iterated by hand rather than with `async for`, so that every
|
|
164
|
+
# chunk has a deadline and a stalled stream ends the turn instead of hanging it.
|
|
165
|
+
cursor = response.chunks.__aiter__()
|
|
166
|
+
while True:
|
|
167
|
+
try:
|
|
168
|
+
chunk = await asyncio.wait_for(cursor.__anext__(), timeout=STALL_SECONDS)
|
|
169
|
+
except StopAsyncIteration:
|
|
170
|
+
break
|
|
171
|
+
except asyncio.TimeoutError:
|
|
172
|
+
emit({"type": "event", "kind": "tool",
|
|
173
|
+
"name": f"no output from the model for {int(STALL_SECONDS // 60)}m — abandoning this turn"})
|
|
174
|
+
try:
|
|
175
|
+
response.cancel()
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
pass
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
kind = type(chunk).__name__
|
|
181
|
+
if kind == "Text":
|
|
182
|
+
buffer.append(chunk.text)
|
|
183
|
+
# On a paragraph break, or once it has said enough to be worth reading.
|
|
184
|
+
if "\n\n" in chunk.text or sum(len(x) for x in buffer) >= FLUSH_CHARS:
|
|
185
|
+
flush()
|
|
186
|
+
elif kind == "Thought":
|
|
187
|
+
# Its reasoning, one line of it: enough to tell a thinking agent from a stuck one
|
|
188
|
+
# without turning the pane into a transcript of everything it considered.
|
|
189
|
+
first_line = (chunk.text or "").strip().splitlines()
|
|
190
|
+
if first_line:
|
|
191
|
+
emit({"type": "event", "kind": "tool", "name": f"thinking · {first_line[0][:110]}"})
|
|
192
|
+
elif kind == "ToolCall":
|
|
193
|
+
name = str(getattr(chunk, "name", "") or "")
|
|
194
|
+
args = getattr(chunk, "args", None) or {}
|
|
195
|
+
if name == "run_command":
|
|
196
|
+
# The SDK has used more than one name for this; rather than guess, take the first
|
|
197
|
+
# string that looks like a command. A bare "run_command" in the pane says nothing.
|
|
198
|
+
command = ""
|
|
199
|
+
for key in ("CommandLine", "command", "Command", "cmd", "script"):
|
|
200
|
+
if str(args.get(key, "") or "").strip():
|
|
201
|
+
command = str(args[key]).strip()
|
|
202
|
+
break
|
|
203
|
+
if not command:
|
|
204
|
+
# Last resort, and it must not pick the WORKING DIRECTORY, which is the
|
|
205
|
+
# longest string in these args and told you nothing: the pane read
|
|
206
|
+
# "run_command · /Users/…/agent-xgv0osv9…" for every shell call.
|
|
207
|
+
guesses = [
|
|
208
|
+
v.strip() for k, v in args.items()
|
|
209
|
+
if isinstance(v, str) and v.strip()
|
|
210
|
+
and k.lower() not in ("cwd", "workingdirectory", "working_dir", "dir", "path")
|
|
211
|
+
and not v.strip().startswith("/")
|
|
212
|
+
]
|
|
213
|
+
command = max(guesses, key=len) if guesses else ""
|
|
214
|
+
if command:
|
|
215
|
+
name = f"run_command · {command[:120]}"
|
|
216
|
+
server = str(getattr(chunk, "server_name", "") or "")
|
|
217
|
+
emit({"type": "event", "kind": "tool", "name": f"{server + ': ' if server else ''}{name}"})
|
|
218
|
+
elif kind == "ToolResult":
|
|
219
|
+
error = getattr(chunk, "error", None)
|
|
220
|
+
if error:
|
|
221
|
+
emit({"type": "event", "kind": "tool",
|
|
222
|
+
"name": f"{getattr(chunk, 'name', 'tool')} failed · {str(error)[:140]}"})
|
|
223
|
+
flush()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def run_turn(agent, brief: str, lines: asyncio.StreamReader) -> str:
|
|
227
|
+
"""
|
|
228
|
+
Run a turn, streaming it, and let the harness interrupt it.
|
|
229
|
+
|
|
230
|
+
A steer cancels the response in flight and sends what was typed as the next message. The
|
|
231
|
+
conversation keeps its history, so the model sees what it had already done and carries on from
|
|
232
|
+
there rather than starting over.
|
|
233
|
+
"""
|
|
234
|
+
queue: "asyncio.Queue[str]" = asyncio.Queue()
|
|
235
|
+
reader = asyncio.create_task(_steers(queue, lines))
|
|
236
|
+
said: list[str] = []
|
|
237
|
+
prompt = brief
|
|
238
|
+
try:
|
|
239
|
+
while True:
|
|
240
|
+
response = await agent.chat(prompt)
|
|
241
|
+
drain = asyncio.create_task(_drain(response, said))
|
|
242
|
+
steer = asyncio.create_task(queue.get())
|
|
243
|
+
done, _ = await asyncio.wait({drain, steer}, return_when=asyncio.FIRST_COMPLETED)
|
|
244
|
+
|
|
245
|
+
if drain in done:
|
|
246
|
+
steer.cancel()
|
|
247
|
+
drain.result() # re-raise anything the stream failed with
|
|
248
|
+
# Something typed while the turn was finishing is the next turn, not lost input.
|
|
249
|
+
if not queue.empty():
|
|
250
|
+
prompt = queue.get_nowait()
|
|
251
|
+
emit({"type": "event", "kind": "tool", "name": "picking up what you typed"})
|
|
252
|
+
continue
|
|
253
|
+
return "\n\n".join(said).strip()
|
|
254
|
+
|
|
255
|
+
prompt = steer.result()
|
|
256
|
+
emit({"type": "event", "kind": "tool", "name": "interrupted — taking what you typed"})
|
|
257
|
+
response.cancel()
|
|
258
|
+
drain.cancel()
|
|
259
|
+
try:
|
|
260
|
+
await drain
|
|
261
|
+
except asyncio.CancelledError:
|
|
262
|
+
pass
|
|
263
|
+
finally:
|
|
264
|
+
reader.cancel()
|
|
265
|
+
|
|
266
|
+
|
|
87
267
|
async def main() -> None:
|
|
88
268
|
global GUARD_URL
|
|
89
|
-
|
|
269
|
+
lines = await stdin_lines()
|
|
270
|
+
first = json.loads((await lines.readline()).decode())
|
|
90
271
|
if first.get("type") != "run":
|
|
91
272
|
emit({"type": "fatal", "message": f"expected a run message, got {first.get('type')!r}"})
|
|
92
273
|
return
|
|
@@ -96,7 +277,9 @@ async def main() -> None:
|
|
|
96
277
|
"system_instructions": first.get("system_instructions") or "",
|
|
97
278
|
"policies": [policy.allow_all()], # the gate above is the policy; this just defers to it
|
|
98
279
|
"hooks": [gate, seen],
|
|
99
|
-
|
|
280
|
+
# What it may CHANGE, which is not always where it starts: a turn with no card reads the
|
|
281
|
+
# shared checkout and writes only to scratch space.
|
|
282
|
+
"workspaces": first.get("workspaces") or [first["cwd"]],
|
|
100
283
|
}
|
|
101
284
|
# FlareDeck, reached over HTTP with the HARNESS's own token — so the model's tools act as the
|
|
102
285
|
# same account the harness listens as. Two identities for one agent is a thing that has gone
|
|
@@ -114,6 +297,28 @@ async def main() -> None:
|
|
|
114
297
|
kwargs["api_key"] = os.environ["GEMINI_API_KEY"]
|
|
115
298
|
if first.get("model"):
|
|
116
299
|
kwargs["model"] = first["model"]
|
|
300
|
+
|
|
301
|
+
# How hard it thinks. The option rides on the Gemini endpoint, which means naming the model as
|
|
302
|
+
# a ModelTarget rather than a bare string — so this is attempted and then abandoned quietly if
|
|
303
|
+
# the SDK disagrees: a tuning knob must never be the reason a card fails. Vertex is left alone
|
|
304
|
+
# because its endpoint is a different type and nothing here has exercised it.
|
|
305
|
+
level = first.get("thinking_level")
|
|
306
|
+
if level and kwargs.get("model") and not kwargs.get("vertex"):
|
|
307
|
+
try:
|
|
308
|
+
from google.antigravity.types import (
|
|
309
|
+
GeminiAPIEndpoint, GeminiModelOptions, ModelTarget, ModelType, ThinkingLevel,
|
|
310
|
+
)
|
|
311
|
+
kwargs["model"] = ModelTarget(
|
|
312
|
+
name=str(kwargs["model"]),
|
|
313
|
+
types=[ModelType.TEXT],
|
|
314
|
+
endpoint=GeminiAPIEndpoint(
|
|
315
|
+
api_key=kwargs.get("api_key"),
|
|
316
|
+
options=GeminiModelOptions(thinking_level=ThinkingLevel(level)),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
except Exception as e: # noqa: BLE001
|
|
320
|
+
emit({"type": "event", "kind": "tool",
|
|
321
|
+
"name": f"thinking level {level} not applied ({type(e).__name__}) — running at the default"})
|
|
117
322
|
if first.get("resume"):
|
|
118
323
|
kwargs["conversation_id"] = first["resume"]
|
|
119
324
|
|
|
@@ -151,10 +356,7 @@ async def main() -> None:
|
|
|
151
356
|
"apiKeySource": "vertex" if kwargs.get("vertex") else "gemini-api-key",
|
|
152
357
|
"permissionMode": "harness-gated",
|
|
153
358
|
"sessionId": str(getattr(agent, "conversation_id", "") or "")})
|
|
154
|
-
|
|
155
|
-
text = await response.text()
|
|
156
|
-
if text:
|
|
157
|
-
emit({"type": "event", "kind": "text", "text": text})
|
|
359
|
+
text = await run_turn(agent, first["brief"], lines)
|
|
158
360
|
# The session id only exists AFTER the first turn — conversation_id is None until then —
|
|
159
361
|
# so the ready event above could never carry it, and the harness, finding no session,
|
|
160
362
|
# skipped the handover entirely: "no session to write a handover from". That handover is
|