@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ /**
3
+ * THE MODEL-FACING CONVERSATION — the thing that was missing.
4
+ *
5
+ * We shipped a conversation product in which the conversation never reached the
6
+ * model. `SessionRunner` created a backend session, sent ONLY the new user
7
+ * text, and destroyed the session again, every turn. The UI transcript was
8
+ * complete and correct the whole time, which is exactly why nobody noticed.
9
+ *
10
+ * WHY A SECOND LOG, BESIDE THE TRANSCRIPT WE ALREADY HAVE. The transcript is a
11
+ * UI PROJECTION. It is shaped for rendering: ephemeral step markers are
12
+ * dropped, tool calls are flattened into a display payload, streaming deltas
13
+ * overwrite each other in place, and content is truncated when it is too large
14
+ * to store. Every one of those is right for a screen and lossy for a prompt.
15
+ * Replaying a projection back at a model is guessing at what the model was
16
+ * told. This log records what the model was ACTUALLY given and what it
17
+ * ACTUALLY said, so a replay is a replay rather than a reconstruction.
18
+ *
19
+ * TWO WAYS TO REMEMBER, AND THE ORDER MATTERS.
20
+ *
21
+ * 1. RESUME THE BACKEND SESSION. When the backend still holds the session —
22
+ * OpenCode does, and every adapter in this package implements
23
+ * `resumeSession` — the cheapest and most faithful thing is to keep
24
+ * talking to it. Nothing is reconstructed, tool results and reasoning stay
25
+ * exactly as the backend recorded them, and no tokens are re-sent.
26
+ *
27
+ * 2. REPLAY THE LOG INTO A FRESH SESSION. When the backend session is gone —
28
+ * a new sandbox, a redeployed runner, a crashed OpenCode — this log is the
29
+ * only memory left. It is a genuine downgrade: the model sees a
30
+ * transcript of the conversation rather than its own context. It is also
31
+ * the difference between a user repeating themselves and a user losing
32
+ * the thread entirely.
33
+ *
34
+ * `resumeFromRev` is what tells the runner which of those it is in.
35
+ */
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.MAX_REPLAY_CHARS = void 0;
38
+ exports.trimHistory = trimHistory;
39
+ exports.buildReplayPrompt = buildReplayPrompt;
40
+ exports.assistantTextFrom = assistantTextFrom;
41
+ /**
42
+ * How much replayed history to put in front of a prompt, in characters.
43
+ *
44
+ * A CEILING, NOT A TARGET. Without one, a long-running session eventually
45
+ * builds a prompt bigger than the model's context window and every turn fails
46
+ * — and it fails at the WORST moment, once the user has invested a long
47
+ * conversation in it. Trimming from the FRONT keeps the most recent exchanges,
48
+ * which are the ones that carry the pronouns.
49
+ *
50
+ * This is a stop-gap and is deliberately labelled as one: the right answer is
51
+ * summarisation at a compaction checkpoint, which is S7's work. A crude ceiling
52
+ * that keeps a session alive beats an elegant plan that is not written yet.
53
+ */
54
+ exports.MAX_REPLAY_CHARS = 24_000;
55
+ /**
56
+ * Rough allowance per message for the `Role: ` prefix and its newline.
57
+ *
58
+ * An estimate, and deliberately a slight over-estimate: the ceiling exists to
59
+ * keep a prompt inside a context window, and erring small costs a little
60
+ * history while erring large costs the turn.
61
+ */
62
+ const PER_MESSAGE_OVERHEAD_CHARS = 16;
63
+ /** Says plainly that the model is not seeing the whole message. */
64
+ const TRUNCATION_NOTE = '[earlier part of this message omitted] ';
65
+ /**
66
+ * Keep the most recent messages that fit inside the ceiling.
67
+ *
68
+ * Walks BACKWARDS from the newest, so the tail is preserved and the head is
69
+ * what gets dropped.
70
+ */
71
+ function trimHistory(history, maxChars = exports.MAX_REPLAY_CHARS) {
72
+ const kept = [];
73
+ let used = 0;
74
+ for (let i = history.length - 1; i >= 0; i -= 1) {
75
+ const message = history[i];
76
+ const cost = message.content.length + PER_MESSAGE_OVERHEAD_CHARS;
77
+ if (used + cost <= maxChars) {
78
+ kept.push(message);
79
+ used += cost;
80
+ continue;
81
+ }
82
+ /*
83
+ * THE NEWEST MESSAGE IS TRUNCATED, NOT WAVED THROUGH.
84
+ *
85
+ * This function's first version broke out of the loop on `kept.length > 0`,
86
+ * which reads as "always keep at least one" and behaves as "the newest
87
+ * message is exempt from the limit". A single very large message therefore
88
+ * defeated the ceiling COMPLETELY — at the one moment the ceiling exists
89
+ * for, since an oversized message is exactly what pushes a prompt past the
90
+ * context window.
91
+ *
92
+ * Truncating from the FRONT of the message keeps its ending, which is
93
+ * where a tool result puts its conclusion and where a long answer puts its
94
+ * summary. The marker is left in the text so the model is not misled into
95
+ * thinking it has the whole thing.
96
+ */
97
+ if (kept.length === 0) {
98
+ const room = maxChars - PER_MESSAGE_OVERHEAD_CHARS - TRUNCATION_NOTE.length;
99
+ if (room > 0) {
100
+ kept.push({
101
+ role: message.role,
102
+ content: TRUNCATION_NOTE + message.content.slice(-room)
103
+ });
104
+ }
105
+ }
106
+ break;
107
+ }
108
+ return kept.reverse();
109
+ }
110
+ /**
111
+ * Build the prompt for a fresh backend session that must be told what happened.
112
+ *
113
+ * THE MARKERS ARE LOAD-BEARING, and the reason is not cosmetic. The replayed
114
+ * history is presented as a QUOTED RECORD inside a single user message, not as
115
+ * a stack of alternating turns, because a fresh session has no message history
116
+ * to stack them into. Without a clear boundary the model cannot tell which part
117
+ * of the text is the conversation so far and which part is the question it is
118
+ * being asked now, and it answers the wrong one — usually by replying to the
119
+ * oldest message in the log.
120
+ *
121
+ * Returns the raw input unchanged when there is nothing to replay, so a first
122
+ * turn is byte-for-byte what it always was and cannot regress.
123
+ */
124
+ function buildReplayPrompt(history, input) {
125
+ const trimmed = trimHistory(history);
126
+ if (trimmed.length === 0)
127
+ return input;
128
+ const lines = trimmed.map((message) => `${message.role === 'user' ? 'User' : 'Assistant'}: ${message.content}`);
129
+ return [
130
+ 'The conversation so far, which you should treat as your own memory of it:',
131
+ '',
132
+ '<conversation>',
133
+ ...lines,
134
+ '</conversation>',
135
+ '',
136
+ 'Continue that conversation. Reply only to the new message below.',
137
+ '',
138
+ input
139
+ ].join('\n');
140
+ }
141
+ /**
142
+ * Reduce a turn's projected parts to the assistant text the model produced.
143
+ *
144
+ * ONLY `text` PARTS, and only assistant ones. A tool call's payload is a record
145
+ * of what the agent DID, and replaying it as prose would tell the model it once
146
+ * said a blob of JSON. What belongs in a conversation log is what was said.
147
+ */
148
+ function assistantTextFrom(parts) {
149
+ return parts
150
+ .filter((part) => part.role === 'assistant' &&
151
+ (part.partType === 'text' || part.partType === 'reasoning-text') &&
152
+ typeof part.content === 'string' &&
153
+ part.content.trim().length > 0)
154
+ .map((part) => part.content.trim())
155
+ .join('\n\n');
156
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * LEASE RENEWAL FOR A SLOW, SILENT TURN — the fix for "a turn is requeued
3
+ * underneath itself".
4
+ *
5
+ * THE PROBLEM. Lease extension rode ENTIRELY on `/ingest` piggy-backing
6
+ * `leaseExpiresAt`: every successful ingest pushes the visibility timeout out by
7
+ * LEASE_TTL_MS (120 s). `renewLease` existed on the client but had ZERO call
8
+ * sites. So a turn that is slow AND produces no parts for over 120 s — one long
9
+ * tool call, one slow model, a big file read — has its lease expire while it is
10
+ * still working. The watchdog then requeues the session onto ANOTHER runner, and
11
+ * two runners run the same turn: two writers interleaving one transcript, double
12
+ * the model spend, and eventually MAX_REQUEUE_ATTEMPTS poisoning the turn.
13
+ *
14
+ * THE FIX, in two halves:
15
+ *
16
+ * 1. Renew on a TIMER for the whole duration of the turn, comfortably inside
17
+ * LEASE_TTL_MS, so a silent turn keeps its lease alive without waiting for
18
+ * output to ingest.
19
+ *
20
+ * 2. Handle `{ renewed: false }` by ABANDONING the turn. A lease that could
21
+ * not be renewed has already expired and been requeued elsewhere; another
22
+ * runner owns the session now. Continuing to spend model budget on work we
23
+ * no longer own is exactly the double-execution this fix exists to stop, so
24
+ * the turn's AbortController is fired and the runner stops.
25
+ */
26
+ import type { LeasedGitHubCredential, SessionCommand } from '../types';
27
+ /** The default renewal cadence: half of LEASE_TTL_MS, so one lost renewal is survivable. */
28
+ export declare const DEFAULT_RENEW_INTERVAL_MS = 60000;
29
+ /**
30
+ * THE CADENCE WHILE A HUMAN IS BEING WAITED ON — and why this route, not the
31
+ * heartbeat.
32
+ *
33
+ * A permission prompt is only useful if it appears, and is answered, at
34
+ * conversational speed. Measured against the channels that existed before this
35
+ * change:
36
+ *
37
+ * heartbeat 30 s the ONLY carrier of sessionCommands
38
+ * lease long-poll 25 s but NOT polled at all during a turn — the loop
39
+ * sees `active.size >= maxConcurrentSessions` and
40
+ * sleeps 500 ms without calling the route
41
+ * lease renew 60 s per-lease, runs for the whole turn, carried
42
+ * no commands
43
+ *
44
+ * So the brief's premise — "commands ride back on BOTH the heartbeat and the
45
+ * lease poll, and the lease poll is far tighter" — does not hold in this build.
46
+ * `sessionCommands` is returned by exactly one route (handlers/runners.ts:391,
47
+ * the heartbeat), and the lease poll is not even called while the turn that
48
+ * needs the answer is running. Every approval would have cost up to 30 s.
49
+ *
50
+ * THE RENEW ROUTE IS THE RIGHT CARRIER, for three reasons that all point the
51
+ * same way:
52
+ * 1. It is the only control-plane call that already runs for the whole
53
+ * duration of a turn, which is exactly the window an approval lives in.
54
+ * 2. It is already per-lease and per-runner authenticated, so it needs no new
55
+ * auth path and no new route — `takePendingCommands` matches on the
56
+ * session's runner and can simply be called there too.
57
+ * 3. It is the same call that keeps the turn from being scavenged. One call
58
+ * proves the turn is alive AND collects the answer, so the two can never
59
+ * disagree about whether the turn still exists.
60
+ *
61
+ * Two seconds matches the runner's own backend poll interval, so the answer and
62
+ * the backend's reaction to it land in the same beat. It is expensive only
63
+ * while a human is genuinely being waited on, which is rare, bounded by the
64
+ * person, and precisely when the extra call is worth making.
65
+ */
66
+ export declare const APPROVAL_RENEW_INTERVAL_MS = 2000;
67
+ export interface RenewedLease {
68
+ renewed: boolean;
69
+ reason?: string;
70
+ /**
71
+ * Commands the control plane had queued for this runner.
72
+ *
73
+ * ADDITIVE AND OPTIONAL, so protocol v1 is untouched: a control plane that
74
+ * does not send them yields `undefined` and the harness behaves exactly as
75
+ * before, collecting commands on the heartbeat alone.
76
+ */
77
+ sessionCommands?: SessionCommand[];
78
+ githubCredential?: LeasedGitHubCredential;
79
+ /**
80
+ * A replacement per-turn model token, present only when the request's
81
+ * `modelTokenExpiresAt` entered the refresh margin. See
82
+ * `SessionRunner.updateModelCredential` for how it reaches a running turn.
83
+ */
84
+ modelToken?: string;
85
+ modelTokenExpiresAt?: string;
86
+ }
87
+ export interface LeaseRenewer {
88
+ renewLease(leaseId: string, status?: undefined, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<RenewedLease>;
89
+ }
90
+ export interface LeaseRenewalOptions {
91
+ client: LeaseRenewer;
92
+ leaseId: string;
93
+ /**
94
+ * Fired when the lease can no longer be renewed. In the harness this is the
95
+ * turn's `AbortController.abort`, which stops the session runner and prevents
96
+ * any further ingest for a session another runner now owns.
97
+ */
98
+ onLost: (reason: string) => void;
99
+ intervalMs?: number;
100
+ /**
101
+ * The cadence to use while `isUrgent()` is true. Defaults to
102
+ * `APPROVAL_RENEW_INTERVAL_MS`.
103
+ */
104
+ urgentIntervalMs?: number;
105
+ /**
106
+ * Commands that came back on a renewal. Called for every renewal that carries
107
+ * any, whatever the cadence — so an ABORT also arrives sooner than the
108
+ * heartbeat would have delivered it, which is a free improvement to a path
109
+ * that used to cost up to 30 s.
110
+ */
111
+ onCommands?: (commands: SessionCommand[]) => void;
112
+ githubTokenExpiresAt?: string;
113
+ onGithubCredential?: (credential: LeasedGitHubCredential) => void;
114
+ /** See `RenewedLease.modelTokenExpiresAt`. */
115
+ modelTokenExpiresAt?: string;
116
+ onModelCredential?: (credential: {
117
+ token: string;
118
+ expiresAt: string;
119
+ }) => void;
120
+ /** Injected in tests so the timer does not cost real wall-clock. */
121
+ setIntervalImpl?: (fn: () => void, ms: number) => NodeJS.Timeout;
122
+ clearIntervalImpl?: (handle: NodeJS.Timeout) => void;
123
+ log?: (message: string, extra?: Record<string, unknown>) => void;
124
+ }
125
+ /**
126
+ * The handle a caller keeps for the life of the turn.
127
+ *
128
+ * `setUrgent` is EVENT-DRIVEN rather than a predicate the loop polls. The loop
129
+ * must not hold its own copy of a state the runner owns — it would drift, and
130
+ * the drift is silent in the expensive direction: the harness would keep
131
+ * renewing every two seconds long after the approval had been answered. The
132
+ * runner already knows the exact instant a prompt opens and closes, so it says
133
+ * so, and the cadence changes immediately instead of at the next tick.
134
+ */
135
+ export interface LeaseRenewalHandle {
136
+ /** Stop renewing. MUST be called when the turn ends. */
137
+ stop(): void;
138
+ /**
139
+ * Switch between the ordinary cadence and the approval cadence. Idempotent:
140
+ * setting the cadence already in force does not re-arm the timer, so a
141
+ * chatty caller cannot postpone a renewal for ever by asking for the same
142
+ * thing repeatedly.
143
+ */
144
+ setUrgent(urgent: boolean): void;
145
+ /** Replace the credential after the control plane rotates it. */
146
+ setCredential?(credential: string): void;
147
+ }
148
+ export interface IsolatedLeaseRenewalOptions extends Omit<LeaseRenewalOptions, 'client' | 'setIntervalImpl' | 'clearIntervalImpl'> {
149
+ url: string;
150
+ runnerCredential: string;
151
+ }
152
+ /**
153
+ * Renew a lease outside the adapter's event loop.
154
+ *
155
+ * The callbacks still run on the main thread, but the lease extension does
156
+ * not depend on them. Messages wait safely in the worker port until Pi yields.
157
+ */
158
+ export declare function startIsolatedLeaseRenewal(options: IsolatedLeaseRenewalOptions): LeaseRenewalHandle;
159
+ /**
160
+ * Keep a lease alive for the duration of a turn. Returns a `stop` function the
161
+ * caller MUST call when the turn ends, so a completed turn does not keep
162
+ * renewing a lease that has already been released.
163
+ *
164
+ * A renewal that fails with a TRANSIENT error (a network blip, a 500) is logged
165
+ * and retried on the next tick — losing one renewal is why the cadence is half
166
+ * the TTL. Only a definitive `renewed: false`, or a FINAL HTTP status, means the
167
+ * lease is truly gone and the turn must be abandoned.
168
+ */
169
+ export declare function startLeaseRenewal(options: LeaseRenewalOptions): LeaseRenewalHandle;