@onepointfour-packs/flaredeck-agent 0.1.0 → 0.2.0

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 (73) hide show
  1. package/dist/agent.d.ts +16 -0
  2. package/dist/agent.js +38 -1
  3. package/dist/agent.js.map +1 -1
  4. package/dist/approval.js +39 -1
  5. package/dist/approval.js.map +1 -1
  6. package/dist/browser.d.ts +19 -0
  7. package/dist/browser.js +261 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/config.d.ts +18 -0
  10. package/dist/config.js +2 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/desperation.d.ts +24 -0
  13. package/dist/desperation.js +64 -0
  14. package/dist/desperation.js.map +1 -0
  15. package/dist/engine-antigravity.d.ts +7 -0
  16. package/dist/engine-antigravity.js +280 -32
  17. package/dist/engine-antigravity.js.map +1 -1
  18. package/dist/engine.d.ts +28 -0
  19. package/dist/engine.js.map +1 -1
  20. package/dist/env.d.ts +5 -1
  21. package/dist/env.js +19 -1
  22. package/dist/env.js.map +1 -1
  23. package/dist/environment.d.ts +87 -2
  24. package/dist/environment.js +213 -11
  25. package/dist/environment.js.map +1 -1
  26. package/dist/flaredeck-proxy.d.ts +47 -1
  27. package/dist/flaredeck-proxy.js +120 -3
  28. package/dist/flaredeck-proxy.js.map +1 -1
  29. package/dist/gitguard.d.ts +6 -0
  30. package/dist/gitguard.js +30 -1
  31. package/dist/gitguard.js.map +1 -1
  32. package/dist/github.d.ts +23 -1
  33. package/dist/github.js +105 -28
  34. package/dist/github.js.map +1 -1
  35. package/dist/journal.d.ts +23 -0
  36. package/dist/journal.js +142 -0
  37. package/dist/journal.js.map +1 -0
  38. package/dist/localmcp.d.ts +50 -0
  39. package/dist/localmcp.js +229 -0
  40. package/dist/localmcp.js.map +1 -0
  41. package/dist/main.js +4 -1
  42. package/dist/main.js.map +1 -1
  43. package/dist/permissions.d.ts +17 -0
  44. package/dist/permissions.js +38 -1
  45. package/dist/permissions.js.map +1 -1
  46. package/dist/runs.js +3 -2
  47. package/dist/runs.js.map +1 -1
  48. package/dist/sandbox.d.ts +34 -0
  49. package/dist/sandbox.js +122 -2
  50. package/dist/sandbox.js.map +1 -1
  51. package/dist/schedules.d.ts +1 -1
  52. package/dist/schedules.js +3 -2
  53. package/dist/schedules.js.map +1 -1
  54. package/dist/serve.js +461 -39
  55. package/dist/serve.js.map +1 -1
  56. package/dist/stream.d.ts +1 -0
  57. package/dist/team.d.ts +1 -1
  58. package/dist/team.js +3 -2
  59. package/dist/team.js.map +1 -1
  60. package/dist/ui.d.ts +2 -0
  61. package/dist/ui.js +34 -39
  62. package/dist/ui.js.map +1 -1
  63. package/dist/updater-python.d.ts +19 -0
  64. package/dist/updater-python.js +149 -0
  65. package/dist/updater-python.js.map +1 -0
  66. package/dist/workspace.d.ts +2 -0
  67. package/dist/workspace.js +1 -1
  68. package/dist/workspace.js.map +1 -1
  69. package/dist/worktree.d.ts +39 -1
  70. package/dist/worktree.js +147 -4
  71. package/dist/worktree.js.map +1 -1
  72. package/package.json +3 -2
  73. 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
- return HookResult(allow=False, message=verdict.get("message", "Refused by the harness."))
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
- first = json.loads(sys.stdin.readline())
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
- "workspaces": [first["cwd"]],
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
- response = await agent.chat(first["brief"])
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