Skip to content

Commit c12e7b3

Browse files
committed
Merge commit 'de2b3352c' into fix/reconcile-rows-b-2026-09-10
2 parents 31236b9 + de2b335 commit c12e7b3

8 files changed

Lines changed: 80 additions & 34 deletions

File tree

apps/pythinker-code/src/cli/sub/web/remote-control.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions):
5858
);
5959
return [
6060
'',
61-
` ${title('Pythinker Remote Control ready')} ${muted(`${getVersion()} (experimental)`)}`,
61+
` ${title('Pythinker Remote Control ready')} ${muted(getVersion())}`,
6262
` ${muted('Use Pythinker Code on this machine from your phone or another computer.')}`,
6363
'',
6464
` ${label('1.')} Scan the QR code, or open ${link(options.url)}`,
@@ -72,7 +72,7 @@ export function formatRemoteControlOutput(options: RemoteControlOutputOptions):
7272
` ${label('QR code PNG: ')}${options.pngPath} ${muted('(open this if the QR above does not scan)')}`,
7373
` ${label('Local UI: ')}${accent(localBase)}${dim(localFrag)} ${muted('(LAN: --host)')}`,
7474
'',
75-
` ${muted('Experimental —')} ${docs} ${muted('·')} ${feedback}`,
75+
` ${muted('Docs:')} ${docs} ${muted('·')} ${feedback}`,
7676
` ${label('Logs: ')}${muted('off (--log-level info)')} ${muted('·')} ${label('Stop: ')}${muted('Ctrl+C')}`,
7777
'',
7878
].join('\n');

apps/pythinker-code/test/cli/options.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Run: pnpm -C apps/pythinker-code exec vitest run test/cli/options.test.ts
66
*/
77

8-
import { describe, expect, it, vi } from 'vitest';
8+
import { describe, expect, it } from 'vitest';
99

1010
import { createProgram } from '#/cli/commands';
1111
import type { CLIOptions } from '#/cli/options';

apps/pythinker-code/test/cli/web/web.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ describe('`pythinker web` opens the browser', () => {
546546
const remoteControlOption = makeProgram()
547547
.commands.find((command) => command.name() === 'web')!
548548
.options.find((option) => option.long === '--remote-control');
549+
expect(remoteControlOption).toBeDefined();
549550
expect(remoteControlOption?.hidden).toBeFalsy();
550551
});
551552
});

docs/reference/slash-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Some commands are only available in the idle state. Executing these commands whi
1616
| `/logout` || Clear credentials for the currently selected account | No |
1717
| `/provider` || Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-—-interactive-provider-management) | Yes |
1818
| `/model` || Switch the LLM model used in the current session | Yes |
19-
| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)). Yes |
19+
| `/secondary-model` | `/subagent-model` | Pick the default model for subagents (writes `[secondary_model] default_model`; see the [subagent model pool](../configuration/config-files.md#subagent-model-pool)) | Yes |
2020
| `/settings` | `/config` | Open the settings panel inside the TUI | Yes |
2121
| `/experiments` | `/experimental` | Open the experimental feature panel | Yes |
2222
| `/permission` || Select a permission mode | Yes |

packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
137137
const result = await op(db);
138138
if (kind === 'write') this.transientWriteFailures = 0;
139139
else this.transientReadFailures = 0;
140+
if (expectedStoreEpoch !== undefined && expectedStoreEpoch !== this.storeEpochCounter) {
141+
throw new QueryStoreRebuiltError();
142+
}
140143
return result;
141144
} catch (error) {
142145
if (classifyStorageError(error) !== 'rebuild') {

packages/agent-gateway/src/search/indexCore.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHash } from 'node:crypto';
2-
import { open, readFile, readdir, stat } from 'node:fs/promises';
2+
import { open, readFile, readdir, stat, type FileHandle } from 'node:fs/promises';
33
import { join, relative } from 'node:path';
44

55
import {
@@ -60,6 +60,7 @@ function legacyFileMetaKey(filePath: string): string {
6060

6161
const WIRE_READ_CHUNK_BYTES = 1 << 20;
6262
const WIRE_BATCH_OPS = 1_000;
63+
const MAX_WIRE_PENDING_BYTES = 4 * 1024 * 1024;
6364
const SYNC_ROUND_BYTE_BUDGET = 64 << 20;
6465
const SYNC_ROUND_TIME_BUDGET_MS = 30_000;
6566
const SYNC_FAILURE_ESCALATION_LIMIT = 5;
@@ -535,6 +536,7 @@ export class SearchIndexCore {
535536
if (result.failed) {
536537
const count = (this.sessionSyncFailures.get(summary.id) ?? 0) + 1;
537538
this.sessionSyncFailures.set(summary.id, count);
539+
failures += 1;
538540
if (count >= SESSION_SYNC_FAILURE_SKIP_LIMIT) {
539541
this.sessionSyncFailures.delete(summary.id);
540542
this.sessionSyncSkips.set(summary.id, { at: Date.now(), updatedAt: summary.updatedAt });
@@ -543,7 +545,6 @@ export class SearchIndexCore {
543545
{ sessionId: summary.id, error: result.error },
544546
);
545547
} else {
546-
failures += 1;
547548
this.log.warn('global search: failed to index session', {
548549
sessionId: summary.id,
549550
error: result.error,
@@ -665,11 +666,21 @@ export class SearchIndexCore {
665666
file: WireFileRef,
666667
budget: SyncRoundBudget,
667668
): Promise<SessionSyncResult> {
669+
let handle: FileHandle;
670+
try {
671+
handle = await open(file.path, 'r');
672+
} catch (error) {
673+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
674+
return { truncated: false, failed: false };
675+
}
676+
return { truncated: false, failed: true, error: errorMessage(error) };
677+
}
668678
let st: { size: number; mtimeMs: number; ino: number };
669679
try {
670-
st = await stat(file.path);
671-
} catch {
672-
return { truncated: false, failed: false };
680+
st = await handle.stat();
681+
} catch (error) {
682+
await handle.close();
683+
return { truncated: false, failed: true, error: errorMessage(error) };
673684
}
674685
const size = st.size;
675686
const metaKey = fileMetaKey(summary.id, file.path);
@@ -730,19 +741,15 @@ export class SearchIndexCore {
730741
if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey });
731742
await db.batch(ops);
732743
}
744+
await handle.close();
733745
return { truncated: false, failed: false };
734746
}
735747

736-
let handle: Awaited<ReturnType<typeof open>>;
737-
try {
738-
handle = await open(file.path, 'r');
739-
} catch (error) {
740-
return { truncated: false, failed: true, error: errorMessage(error) };
741-
}
742748
const ops: BatchInputOp<SearchDoc>[] = [];
743749
let byteCursor = offset;
744750
let position = offset;
745751
let wireError: unknown;
752+
let pendingOverflow = false;
746753
try {
747754
let pending: Buffer = EMPTY_BUFFER;
748755
let finishing = false;
@@ -796,6 +803,10 @@ export class SearchIndexCore {
796803
pending.length > 0
797804
? Buffer.concat([pending, slice.subarray(start)])
798805
: Buffer.from(slice.subarray(start));
806+
if (pending.length > MAX_WIRE_PENDING_BYTES) {
807+
pendingOverflow = true;
808+
break;
809+
}
799810
if (finishing && completedRecord) break;
800811
if (ops.length >= WIRE_BATCH_OPS) {
801812
ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) });
@@ -811,7 +822,7 @@ export class SearchIndexCore {
811822
await handle.close();
812823
}
813824

814-
const truncated = position < size && syncBudgetExhausted(budget);
825+
const truncated = pendingOverflow || (position < size && syncBudgetExhausted(budget));
815826
if (byteCursor !== offset || legacyKey !== null) {
816827
ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) });
817828
if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey });

packages/agent-gateway/test/search/searchService.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,17 @@ describe('GlobalSearchService', () => {
423423
expect(page.items.length).toBe(2);
424424
expect(page.items.some((h) => h.snippet.includes('appended'))).toBe(true);
425425
});
426+
it('keeps the index incomplete while a wire record exceeds the pending cap', async () => {
427+
const s1 = summary('s1', 'overflow', T1);
428+
const file = await writeWire(home!, 's1', 'main', [userLine('\u82F9\u679C head', T1)]);
429+
await appendFile(file, `{"kind":"step","pad":"${'x'.repeat(5 * 1024 * 1024)}"`, 'utf8');
430+
const service = track(makeService(home!, staticIndex([s1])));
431+
432+
await service.reindex();
433+
const page = await service.search({ query: '\u82F9\u679C' });
434+
expect(page.items.length).toBe(1);
435+
expect(page.indexState.state).not.toBe('ready');
436+
});
426437

427438
it('reports indexState building before the first full sync and ready after', async () => {
428439
const s1 = summary('s1', 'state', T1);

packages/remote-control/src/remote-control.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export function resolveRelayKey(
5353

5454
const MAX_HTTP_HEADER_BYTES = 64 * 1024;
5555
const MAX_HTTP_REQUEST_BYTES = 10 * 1024 * 1024;
56+
const MAX_HTTP_RESPONSE_BYTES = 64 * 1024 * 1024;
5657
const HTTP_REQUEST_TIMEOUT_MS = 30_000;
5758
const REGISTER_TIMEOUT_MS = 10_000;
5859
const MAX_RECONNECT_DELAY_MS = 30_000;
@@ -153,7 +154,7 @@ export function buildRemoteControlUrl(
153154
relayOrigin = REMOTE_CONTROL_RELAY_ORIGIN,
154155
): string {
155156
const url = new URL(relayOrigin);
156-
const relayPath = url.pathname.replace(/\/+$/, '');
157+
const relayPath = stripTrailingSlashes(url.pathname);
157158
const devicePath = `${relayPath}/devices/${encodeURIComponent(deviceId)}`;
158159
url.pathname =
159160
sessionId === undefined
@@ -231,23 +232,34 @@ export function filterForwardRequestHeaders(
231232
return result;
232233
}
233234

235+
function stripTrailingSlashes(value: string): string {
236+
let end = value.length;
237+
while (end > 0 && value.codePointAt(end - 1) === 47) end -= 1;
238+
return end === value.length ? value : value.slice(0, end);
239+
}
240+
241+
function findHeadTagEnd(text: string): number {
242+
const start = /<head(?=[\s>])/i.exec(text);
243+
if (start === null) return -1;
244+
const close = text.indexOf('>', start.index + 5);
245+
return close === -1 ? -1 : close + 1;
246+
}
247+
234248
export function rewriteRemoteControlResponse(
235249
contentType: string,
236250
body: Buffer,
237251
publicPrefix: string,
238252
): Buffer {
239-
const normalizedPrefix = publicPrefix.replace(/\/+$/, '');
253+
const normalizedPrefix = stripTrailingSlashes(publicPrefix);
240254
if (contentType.toLowerCase().includes('text/html')) {
241255
const prefixLiteral = scriptStringLiteral(normalizedPrefix);
242256
const injected = `<script>(function(){var p=${prefixLiteral};try{sessionStorage.setItem('pythinker-desktop-server-origin',location.origin+p)}catch(e){}var w=function(f){return function(s,t,u){if(typeof u==='string'&&u.charAt(0)==='/'&&u.indexOf(p)!==0)u=p+u;return f.apply(this,[s,t,u])}};history.pushState=w(history.pushState);history.replaceState=w(history.replaceState)})();</script>`;
243257
let text = body.toString('utf8');
244-
const headMatch = /<head(?:\s[^>]*)?>/i.exec(text);
258+
const headEnd = findHeadTagEnd(text);
245259
text =
246-
headMatch === null
260+
headEnd === -1
247261
? injected + text
248-
: text.slice(0, headMatch.index + headMatch[0].length) +
249-
injected +
250-
text.slice(headMatch.index + headMatch[0].length);
262+
: text.slice(0, headEnd) + injected + text.slice(headEnd);
251263
text = text.replaceAll(/\bsrc="\//g, `src="${normalizedPrefix}/`);
252264
text = text.replaceAll(/\bhref="\//g, `href="${normalizedPrefix}/`);
253265
return Buffer.from(text);
@@ -318,7 +330,7 @@ export async function startRemoteControl(
318330
const deviceName = hostname();
319331
const url = buildRemoteControlUrl(deviceId, undefined, relayOrigin);
320332
const lock = await acquireRemoteControlLock(options.homeDir, {
321-
localOrigin: options.localOrigin.replace(/\/+$/, ''),
333+
localOrigin: stripTrailingSlashes(options.localOrigin),
322334
deviceId,
323335
url,
324336
});
@@ -387,7 +399,7 @@ class RemoteControlClient {
387399
readonly localServerToken: () => string;
388400
},
389401
) {
390-
this.localOrigin = options.localOrigin.replace(/\/+$/, '');
402+
this.localOrigin = stripTrailingSlashes(options.localOrigin);
391403
this.localServerToken = options.localServerToken;
392404
this.clientVersion = options.clientVersion;
393405
this.relayOrigin = options.relayOrigin;
@@ -758,7 +770,7 @@ class RemoteControlClient {
758770
}
759771

760772
private publicPrefix(): string {
761-
const relayPath = new URL(this.relayOrigin).pathname.replace(/\/+$/, '');
773+
const relayPath = stripTrailingSlashes(new URL(this.relayOrigin).pathname);
762774
return `${relayPath}/devices/${encodeURIComponent(this.deviceId)}`;
763775
}
764776

@@ -911,7 +923,17 @@ function requestLocalHttp(
911923
},
912924
(response) => {
913925
const chunks: Buffer[] = [];
914-
response.on('data', (chunk: Buffer | string) => chunks.push(Buffer.from(chunk)));
926+
let receivedBytes = 0;
927+
response.on('data', (chunk: Buffer | string) => {
928+
receivedBytes += chunk.length;
929+
if (receivedBytes > MAX_HTTP_RESPONSE_BYTES) {
930+
response.destroy(
931+
new Error(`Remote Control response exceeds ${MAX_HTTP_RESPONSE_BYTES} bytes`),
932+
);
933+
return;
934+
}
935+
chunks.push(Buffer.from(chunk));
936+
});
915937
response.once('error', reject);
916938
response.once('end', () => {
917939
void (async (): Promise<Buffer> => {
@@ -1007,7 +1029,7 @@ function bridgeSockets(
10071029
left: WebSocket,
10081030
right: WebSocket,
10091031
onClose: () => void,
1010-
earlyLeftFrames?: [RawData, boolean][],
1032+
earlyLeftFrames: [RawData, boolean][],
10111033
): void {
10121034
let closed = false;
10131035
const closeBoth = (code = 1000, reason = Buffer.alloc(0)): void => {
@@ -1018,11 +1040,9 @@ function bridgeSockets(
10181040
if (left.readyState === WebSocket.OPEN) left.close(safeCode, reason);
10191041
if (right.readyState === WebSocket.OPEN) right.close(safeCode, reason);
10201042
};
1021-
if (earlyLeftFrames !== undefined) {
1022-
left.removeAllListeners('message');
1023-
for (const [data, isBinary] of earlyLeftFrames) {
1024-
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
1025-
}
1043+
left.removeAllListeners('message');
1044+
for (const [data, isBinary] of earlyLeftFrames) {
1045+
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
10261046
}
10271047
left.on('message', (data, isBinary) => {
10281048
if (right.readyState === WebSocket.OPEN) right.send(data, { binary: isBinary });
@@ -1050,7 +1070,7 @@ function isValidCloseCode(code: number): boolean {
10501070
function relayWebSocketUrl(origin: string, path: string): string {
10511071
const url = new URL(origin);
10521072
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
1053-
const relayPath = url.pathname.replace(/\/+$/, '');
1073+
const relayPath = stripTrailingSlashes(url.pathname);
10541074
const [pathname, query] = path.split('?', 2);
10551075
url.pathname = `${relayPath}${pathname}`;
10561076
url.search = query === undefined ? '' : query;

0 commit comments

Comments
 (0)