Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/src/features/threads/thread-list-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET;
const SIDEBAR_ROW_RADIUS = 12;

function pullRequestTintColor(
pr: Pick<ThreadPrPresentation, "state" | "isDraft">,
pr: Pick<ThreadPrPresentation, "state" | "isDraft" | "others">,
colorScheme: "light" | "dark",
) {
const dark = colorScheme === "dark";
if (pr.state === "open" && pr.isDraft === true) {
if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) {
return dark ? "#a1a1aa" : "#71717a";
}
switch (pr.state) {
Expand Down
6 changes: 3 additions & 3 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -871,9 +871,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
{pr ? (
<View className="flex-row items-center gap-1" accessibilityLabel={pr.accessibilityLabel}>
{pr.kind === "stack" ? (
{pr.kind === "stack" || pr.others > 0 ? (
<SymbolView
name="square.3.layers.3d"
name={pr.kind === "stack" ? "square.3.layers.3d" : "arrow.triangle.pull"}
size={12}
tintColorClassName={
selected
Expand All @@ -896,7 +896,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
style={{ fontFamily: MONO_FONT }}
>
{pr.kind === "stack" ? pr.label : `#${pr.label}`}
{pr.kind === "stack" || pr.others > 0 ? pr.label : `#${pr.label}`}
</Text>
</View>
) : null}
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request";
type WorkContentIcon = AppSymbolName | "browser" | "device" | "t3-code" | "pull-request";

function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
Expand All @@ -97,7 +97,9 @@ function WorkLogIcon(props: {
? "arrow.triangle.pull"
: props.icon === "browser"
? { ios: "globe", android: "public" }
: props.icon
: props.icon === "device"
? { ios: "iphone", android: "smartphone" }
: props.icon
}
size={14}
weight="medium"
Expand Down Expand Up @@ -899,7 +901,7 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
Expand Down Expand Up @@ -1248,6 +1250,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
return { ios: "square.and.pencil", android: "edit" };
case "command":
return { ios: "terminal", android: "terminal" };
case "device":
return { ios: "iphone", android: "smartphone" };
case "browser":
case "search":
return { ios: "globe", android: "public" };
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export type ThreadFeedEntry =
readonly summaryKind: ToolGroupSummaryKind;
readonly toolSurface?: WorkLogEntry["toolSurface"];
readonly toolIcon?: WorkLogEntry["toolIcon"];
readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
Expand Down
15 changes: 12 additions & 3 deletions apps/mobile/src/state/thread-pr-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ export interface ThreadPrPresentation {
readonly number: number;
readonly state: ThreadPr["state"] | null;
readonly kind: "pull-request" | "stack";
readonly others: number;
readonly isDraft: boolean;
/** Provider-side last activity, bounding when a terminal state landed. */
readonly updatedAt: string | null;
readonly url: string;
/** Compact pull request number label, e.g. "3774". */
/** Compact pull request number or linked count, e.g. "3774" or "+2". */
readonly label: string;
/** Full, provider-aware label for assistive technologies. */
readonly accessibilityLabel: string;
Expand All @@ -42,6 +43,7 @@ export function presentThreadPr(
const isDraft = pr.state === "open" && pr.isDraft === true;
return {
kind: "pull-request",
others: 0,
number: pr.number,
state: pr.state,
isDraft,
Expand All @@ -63,12 +65,16 @@ export function presentThreadLinkedPullRequests(
const snapshot = link.snapshot;
const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null);
const isDraft = snapshot?.isDraft === true && state === "open";
const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null;
const label =
badge.kind === "stack"
? String(badge.layers)
: `${link.number}${badge.others > 0 ? ` +${badge.others}` : ""}`;
: linkedCount !== null
? `+${linkedCount}`
: String(link.number);
return {
kind: badge.kind,
others: badge.kind === "pull-request" ? badge.others : 0,
number: link.number,
state,
isDraft,
Expand All @@ -79,7 +85,10 @@ export function presentThreadLinkedPullRequests(
badge.kind === "stack"
? `${badge.layers} pull requests in stack, ${state ?? "status pending"}`
: `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`,
textClassName: state === null || isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[state],
textClassName:
linkedCount !== null || state === null || isDraft
? "text-foreground-muted"
: PR_STATE_TEXT_CLASS[state],
};
}

Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/state/use-thread-pr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ describe("presentThreadLinkedPullRequests", () => {
it("counts unrelated links without labelling them a stack", () => {
expect(presentThreadLinkedPullRequests([linkedPr(1), linkedPr(2)])).toMatchObject({
kind: "pull-request",
label: "1 +1",
label: "+2",
others: 1,
textClassName: "text-foreground-muted",
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@effect/vitest": "catalog:",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@t3tools/web": "workspace:*",
"@types/bun": "1.3.14",
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.previewAutomationFocusHost]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribePreviewEvents]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeDiscoveredLocalServers]: AuthOrchestrationReadScope,
[WS_METHODS.deviceConfigure]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceTestHost]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceList]: AuthOrchestrationReadScope,
[WS_METHODS.deviceOpen]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceClose]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceShutdown]: AuthOrchestrationOperateScope,
[WS_METHODS.deviceDetail]: AuthOrchestrationReadScope,
[WS_METHODS.deviceAction]: AuthOrchestrationOperateScope,
[WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope,
[WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope,
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/device/AgentDeviceShim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// @effect-diagnostics preferSchemaOverJson:off - JSON string literals embed paths safely into generated JavaScript.
/**
* A directory holding an `agent-device` launcher that runs the pinned install
* with the server's Node. Prepended to provider subprocess PATHs so the agent
* types `agent-device …` and gets the version the injected instructions were
* written for, regardless of what is or is not globally installed.
*/
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

const SHIM_DIR = "device/bin";

export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: {
readonly entryPath: string;
readonly stateDir: string;
}) {
const { entryPath } = input;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const shimDir = path.join(input.stateDir, SHIM_DIR);
yield* fs.makeDirectory(shimDir, { recursive: true });
const node = process.execPath;
const launcherPath = path.join(shimDir, "agent-device-launcher.mjs");
yield* fs.writeFileString(
launcherPath,
`import { spawn } from "node:child_process";
const args = process.argv.slice(2);
const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]);
const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); };
if (!informational && !(hasValue("--config") && hasValue("--session"))) {
console.error("Call device_open first and include its --config and --session flags.");
process.exit(1);
}
const env = { ...process.env };
delete env.AGENT_DEVICE_DAEMON_BASE_URL;
delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
delete env.AGENT_DEVICE_CONFIG;
const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env });
child.on("error", error => { console.error(error.message); process.exitCode = 1; });
child.on("exit", code => { process.exitCode = code ?? 1; });
`,
);
if (platform === "win32") {
const script = `@echo off\r\n"${node}" "${launcherPath}" %*\r\n`;
yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script);
} else {
const command = [node, launcherPath]
.map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'")
.join(" ");
const script = `#!/bin/sh\nexec ${command} "$@"\n`;
const shimPath = path.join(shimDir, "agent-device");
yield* fs.writeFileString(shimPath, script);
yield* fs.chmod(shimPath, 0o755);
}
return shimDir;
});
89 changes: 89 additions & 0 deletions apps/server/src/device/AgentDeviceTarget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { describe, expect, it } from "@effect/vitest";
import * as NodeChildProcess from "node:child_process";
import * as NodeUtil from "node:util";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts";
import {
agentDeviceConfigPath,
agentDeviceSession,
writeAgentDeviceConfig,
} from "./AgentDeviceTarget.ts";

const exec = NodeUtil.promisify(NodeChildProcess.execFile);

describe("host-bound agent commands", () => {
it.effect("runs two hosts concurrently and only updates the reconnected host", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
const platform = yield* HostProcessPlatform;
const dir = path.join(
temp,
platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`",
);
yield* fs.makeDirectory(dir);
const entryPath = path.join(dir, "cli.mjs");
yield* fs.writeFileString(
entryPath,
`import { readFileSync } from 'node:fs';
const args = process.argv.slice(2);
console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
);
const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
const files = ["mini", "android"].map((host) => agentDeviceConfigPath(dir, host, path));
for (const [index, file] of files.entries())
yield* writeAgentDeviceConfig(file, {
baseUrl: `http://127.0.0.1:${1000 + index}`,
token: `token-${index}`,
entryPath,
});
const invoke = (file: string) =>
exec(
platform === "win32" ? process.execPath : path.join(shim, "agent-device"),
[
...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []),
"snapshot",
"--config",
file,
"--session",
"test-session",
],
{ env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } },
).then((result) => JSON.parse(result.stdout));
expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([
{ daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" },
{ daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" },
]);
const second = yield* fs.readFileString(files[1]!);
yield* writeAgentDeviceConfig(files[0]!, {
baseUrl: "http://127.0.0.1:2000",
token: "new",
entryPath,
});
expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
expect(yield* fs.readFileString(files[1]!)).toBe(second);
expect(agentDeviceSession("thread", "mini", "same-id")).not.toBe(
agentDeviceSession("thread", "android", "same-id"),
);
for (const args of [
["snapshot"],
["snapshot", "--config", files[0]!],
["snapshot", "--config", "help"],
["snapshot", "--config", files[0]!, "--session"],
]) {
yield* Effect.promise(() =>
expect(
exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]),
).rejects.toThrow("Call device_open first"),
);
}
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
});
43 changes: 43 additions & 0 deletions apps/server/src/device/AgentDeviceTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as NodeCrypto from "node:crypto";
import * as Schema from "effect/Schema";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import type { AgentDeviceEndpoint } from "./DeviceHost.ts";

const encodeEndpoint = Schema.encodeEffect(
Schema.fromJsonString(
Schema.Struct({ daemonBaseUrl: Schema.String, daemonAuthToken: Schema.String }),
),
);

const key = (value: string) =>
NodeCrypto.createHash("sha256").update(value).digest("hex").slice(0, 24);

/** A stable file per host lets forwarded endpoints change without retargeting other commands. */
export const agentDeviceConfigPath = (stateDir: string, hostId: string, path: Path.Path) =>
path.join(stateDir, "device", "hosts", `${key(hostId)}.json`);

export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>
`t3-${key(JSON.stringify([threadId, hostId, deviceId]))}`;

export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
file: string,
endpoint: AgentDeviceEndpoint,
) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.makeDirectory(path.dirname(file), { recursive: true });
const content = yield* encodeEndpoint({
daemonBaseUrl: endpoint.baseUrl,
daemonAuthToken: endpoint.token,
});
if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return;
const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" });
yield* Effect.gen(function* () {
yield* fs.chmod(temporary, 0o600);
yield* fs.writeFileString(temporary, content);
yield* fs.rename(temporary, file);
}).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore)));
});
Loading
Loading