Skip to content

Commit 083efd0

Browse files
committed
Merge remote-tracking branch 'origin/issue-watch/36000' into issue-watch/36000
2 parents e389e66 + 7467576 commit 083efd0

132 files changed

Lines changed: 3565 additions & 528 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/agent-context-budget.yml‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ on:
1010
permissions:
1111
contents: read
1212

13-
concurrency:
14-
group: agent-context-budget-${{ github.event.pull_request.number }}
15-
cancel-in-progress: true
16-
13+
# No concurrency group: label events arrive in bursts, and a cancelled run reports as a failed check.
1714
jobs:
1815
budget:
1916
name: Agent context budget

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# T3 Code
22

3-
T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro) and serves web, desktop, and mobile clients.
3+
T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity, Kiro, Pi, Muse Code, fx) and serves web, desktop, and mobile clients.
44

55
You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.
66

@@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste
6868

6969
- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
7070
- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
71-
- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, and Kiro each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
71+
- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, Antigravity, Kiro, Pi, Muse Code, fx, and ACP Registry each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
7272
- **Agents.** A capability a user can trigger is usually one an agent should reach through MCP tools, and scheduled tasks run the same paths. That only works when it is a service method, not handler code.
7373
- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
7474
- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.

‎apps/desktop/src/app/DesktopCliShim.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const run = (shim: string, args: ReadonlyArray<string>, env: NodeJS.ProcessEnv =
3939
});
4040

4141
const REPORTER =
42-
'#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n';
42+
'#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME appimage=$APPIMAGE"\nprintf "%s\\n" "$@"\nexit 3\n';
4343

4444
const fakeAppImage = (canMount = true, app = REPORTER) => {
4545
const root = tempRoot();
@@ -150,6 +150,14 @@ describe("renderCliShim", () => {
150150
);
151151
});
152152

153+
// The server skips its compile cache only under APPIMAGE; each mount path is new.
154+
it("tells the CLI it runs from the mounted AppImage", () => {
155+
const { appImage, shim } = fakeAppImage();
156+
const result = run(shim, ["--version"]);
157+
expect(result.status).toBe(3);
158+
expect(result.stdout).toContain(`appimage=${appImage}`);
159+
});
160+
153161
it("keeps the install's T3 home under sudo, unless one is set", () => {
154162
const root = tempRoot();
155163
writeExecutable(NodePath.join(root, "app"), REPORTER);

‎apps/desktop/src/app/DesktopCliShim.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ export const renderCliShim = (input: {
109109
' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }',
110110
' mount="$work/squashfs-root"',
111111
"fi",
112+
// The runtime sets this when it launches the app; the CLI skips its path-keyed compile cache on it.
113+
'export APPIMAGE="$app"',
112114
run("$mount"),
113115
"",
114116
].join("\n");

‎apps/desktop/src/app/DesktopExitChildren.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as NodeChildProcess from "node:child_process";
33
import * as NodeFS from "node:fs";
44
import { assert, describe, it } from "@effect/vitest";
55

6-
import { stopChildProcessesBeforeExit } from "./DesktopExitChildren.ts";
6+
import { stopChildProcessesBeforeExit, stopZygotesBeforeExit } from "./DesktopExitChildren.ts";
77

88
const isRunning = (pid: number) => {
99
try {
@@ -39,4 +39,24 @@ describe.runIf(NodeFS.existsSync("/proc/self/task"))("stopChildProcessesBeforeEx
3939
parent.kill("SIGKILL");
4040
}
4141
});
42+
43+
it("kills zygote children before returning and leaves other helpers running", async () => {
44+
const spawnTitled = async (title: string) => {
45+
const child = NodeChildProcess.spawn("sleep", ["60"], { argv0: title, stdio: "ignore" });
46+
await new Promise((resolve) => child.once("spawn", resolve));
47+
return child;
48+
};
49+
// Chromium rewrites a zygote's argv into one space-separated title.
50+
const zygote = await spawnTitled("/opt/T3 Code/t3code --type=zygote --no-sandbox");
51+
const utility = await spawnTitled("/opt/T3 Code/t3code --type=utility");
52+
try {
53+
stopZygotesBeforeExit(process.pid);
54+
55+
assert.isFalse(isRunning(zygote.pid!));
56+
assert.isTrue(isRunning(utility.pid!));
57+
} finally {
58+
zygote.kill("SIGKILL");
59+
utility.kill("SIGKILL");
60+
}
61+
});
4262
});

‎apps/desktop/src/app/DesktopExitChildren.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ const childPids = (pid: number): ReadonlyArray<number> => {
2929
);
3030
};
3131

32+
// A zygote rewrites its argv into one space-separated title.
33+
const isZygote = (pid: number): boolean =>
34+
(readProc(`/proc/${pid}/cmdline`) ?? "")
35+
.replaceAll("\0", " ")
36+
.split(" ")
37+
.includes("--type=zygote");
38+
3239
// A zombie has already released its memory mappings.
3340
const isRunning = (pid: number): boolean => {
3441
const stat = readProc(`/proc/${pid}/stat`);
@@ -68,3 +75,13 @@ export const stopChildProcessesBeforeExit = (pid: number): ReadonlyArray<number>
6875
waitUntilStopped(survivors, KILL_WAIT_MS);
6976
return survivors;
7077
};
78+
79+
/**
80+
* Chromium's zygotes exit only after main does, so on an AppImage they outlive the mount even on
81+
* an orderly quit. Kills them while main still holds the image mounted. Linux only (/proc).
82+
*/
83+
export const stopZygotesBeforeExit = (pid: number): void => {
84+
const zygotes = childPids(pid).filter(isZygote);
85+
signal(zygotes, "SIGKILL");
86+
waitUntilStopped(zygotes, KILL_WAIT_MS);
87+
};

‎apps/desktop/src/app/DesktopLifecycle.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest";
22
import * as Deferred from "effect/Deferred";
33
import * as Effect from "effect/Effect";
44
import * as Layer from "effect/Layer";
5+
import * as Option from "effect/Option";
56
import * as Ref from "effect/Ref";
67
import * as TestClock from "effect/testing/TestClock";
78

@@ -117,6 +118,7 @@ describe("DesktopLifecycle", () => {
117118
const layerEnvironment = Layer.succeed(DesktopEnvironment.DesktopEnvironment, {
118119
platform,
119120
isDevelopment: false,
121+
appImagePath: Option.none(),
120122
} as DesktopEnvironment.DesktopEnvironment["Service"]);
121123

122124
const layer = DesktopLifecycle.layer.pipe(

‎apps/desktop/src/app/DesktopLifecycle.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as Context from "effect/Context";
33
import * as Duration from "effect/Duration";
44
import * as Effect from "effect/Effect";
55
import * as Layer from "effect/Layer";
6+
import * as Option from "effect/Option";
67
import * as Ref from "effect/Ref";
78
import * as Schema from "effect/Schema";
89
import * as Scope from "effect/Scope";
@@ -12,7 +13,7 @@ import type * as Electron from "electron";
1213
import * as HostProcess from "@t3tools/shared/HostProcess";
1314

1415
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
15-
import { stopChildProcessesBeforeExit } from "./DesktopExitChildren.ts";
16+
import { stopChildProcessesBeforeExit, stopZygotesBeforeExit } from "./DesktopExitChildren.ts";
1617
import { makeComponentLogger } from "./DesktopObservability.ts";
1718
import * as DesktopShutdown from "./DesktopShutdown.ts";
1819
import * as ElectronApp from "../electron/ElectronApp.ts";
@@ -340,6 +341,13 @@ export const make = DesktopLifecycle.of({
340341
quitFromSignal("SIGTERM", runEffect, quit);
341342
});
342343
}
344+
345+
// Unscoped: the app scope closes before Electron exits, and every exit path must run this.
346+
if (environment.platform === "linux" && Option.isSome(environment.appImagePath)) {
347+
yield* Effect.sync(() => {
348+
process.once("exit", () => stopZygotesBeforeExit(process.pid));
349+
});
350+
}
343351
}).pipe(Effect.withSpan("desktop.lifecycle.register")),
344352
});
345353

‎apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
6767
private var streamedOutput: TerminalOutputBuffer? = null
6868
private var cols = 0
6969
private var rows = 0
70+
private var cellSize = 0 to 0
7071
private var measuredCols = 0
7172
private var measuredRows = 0
7273
private var clearingInput = false
@@ -356,25 +357,24 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
356357
applyGrid()
357358
}
358359

360+
@Suppress("ComplexCondition")
359361
private fun applyGrid() {
360362
if (measuredCols <= 0 || measuredRows <= 0 || isCleanedUp) return
361363
val shared = sharedGrid.takeIf { it.size == 2 && it[0] > 0 && it[1] > 0 }
362364
val nextCols = shared?.get(0)?.coerceAtMost(measuredCols) ?: measuredCols
363365
val nextRows = shared?.get(1)?.coerceAtMost(measuredRows) ?: measuredRows
364-
if (nextCols == cols && nextRows == rows && terminalHandle != 0L) return
366+
val nextCellSize = terminalCanvas.cellWidthPx.toInt() to terminalCanvas.cellHeightPx.toInt()
367+
if (nextCols == cols && nextRows == rows && nextCellSize == cellSize && terminalHandle != 0L) {
368+
return
369+
}
365370
cols = nextCols
366371
rows = nextRows
372+
cellSize = nextCellSize
367373
val response = if (terminalHandle == 0L) {
368374
createTerminal()
369375
ByteArray(0)
370376
} else {
371-
GhosttyBridge.nativeResize(
372-
terminalHandle,
373-
cols,
374-
rows,
375-
terminalCanvas.cellWidthPx.toInt(),
376-
terminalCanvas.cellHeightPx.toInt(),
377-
)
377+
GhosttyBridge.nativeResize(terminalHandle, cols, rows, cellSize.first, cellSize.second)
378378
}
379379
emitResponse(response)
380380
if (!hasPendingBuffer) feedPendingBuffer()
@@ -387,8 +387,8 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
387387
terminalHandle = GhosttyBridge.nativeCreate(
388388
cols,
389389
rows,
390-
terminalCanvas.cellWidthPx.toInt(),
391-
terminalCanvas.cellHeightPx.toInt(),
390+
cellSize.first,
391+
cellSize.second,
392392
foregroundColorValue,
393393
backgroundColorValue,
394394
cursorColorValue,

‎apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TextInputWrapper } from "expo-paste-input";
33
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
44
import { useMemo, useState } from "react";
55
import { ScrollView, View, useWindowDimensions } from "react-native";
6+
import * as Dialog from "../../components/ui/Dialog";
67
import { FilePreviewModal, type FilePreviewSource } from "../../components/ui/FilePreviewModal";
78

89
import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
@@ -86,13 +87,14 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
8687
const handleNativePaste = useNativePaste((uris) => {
8788
void (async () => {
8889
try {
89-
const images = await convertPastedImagesToAttachments({
90+
const result = await convertPastedImagesToAttachments({
9091
uris,
9192
existingCount: attachments.length,
9293
});
93-
if (images.length > 0) {
94-
setAttachments((current) => [...current, ...images]);
94+
if (result.attachments.length > 0) {
95+
setAttachments((current) => [...current, ...result.attachments]);
9596
}
97+
if (result.error) Dialog.alert("Could not paste image", result.error);
9698
} catch (error) {
9799
console.error("[review comment] error converting pasted images", error);
98100
}

0 commit comments

Comments
 (0)