Skip to content
18 changes: 17 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,21 @@ jobs:
node-version-file: package.json

- name: Install dependencies
run: bun install --frozen-lockfile --filter=@t3tools/scripts
run: bun install --frozen-lockfile

- name: Align package versions to release version
run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}"

- name: Build t3 server package
run: bun run build --filter=t3

- name: Pack t3 CLI tarball
run: |
mkdir -p release-assets
node apps/server/scripts/cli.ts pack \
--app-version "${{ needs.preflight.outputs.version }}" \
--pack-destination "$(pwd)/release-assets" \
--verbose

- name: Download all desktop artifacts
uses: actions/download-artifact@v8
Expand All @@ -207,6 +221,7 @@ jobs:
release-assets/*.zip
release-assets/*.AppImage
release-assets/*.yml
release-assets/*.tgz
fail_on_unmatched_files: true
token: ${{ secrets.GITHUB_TOKEN }}

Expand All @@ -225,5 +240,6 @@ jobs:
release-assets/*.zip
release-assets/*.AppImage
release-assets/*.yml
release-assets/*.tgz
fail_on_unmatched_files: true
token: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ function resolveDesktopAppStageLabel(input: {
return "Dev";
}

return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Bscholar";
}

function resolveDesktopAppBranding(input: {
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as Option from "effect/Option";
import * as Electron from "electron";

import * as NetService from "@t3tools/shared/Net";
import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
import { parseReleaseRepository, resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
import serverPackageJson from "../../server/package.json" with { type: "json" };

Expand Down Expand Up @@ -71,11 +71,15 @@ const resolveDesktopSshCliRunner = (
nodeEngineRange: serverPackageJson.engines.node,
};
}
// Public-fork friendly: prefer the GitHub Release tarball URL so remote
// hosts don't need any registry auth (.npmrc tokens) to `npx --yes` t3.
const releaseRepository = parseReleaseRepository(serverPackageJson.repository.url);
return {
packageSpec: resolveRemoteT3CliPackageSpec({
appVersion: environment.appVersion,
updateChannel: settings.updateChannel,
isDevelopment: environment.isDevelopment,
...(releaseRepository ? { releaseRepository } : {}),
}),
nodeEngineRange: serverPackageJson.engines.node,
};
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/pingdotgg/t3code",
"url": "https://github.com/bscholar-tt/t3code",
"directory": "apps/server"
},
"bin": {
Expand Down
112 changes: 111 additions & 1 deletion apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,123 @@ const publishCmd = Command.make(
}),
).pipe(Command.withDescription("Publish the server package to npm."));

// ---------------------------------------------------------------------------
// pack subcommand
// ---------------------------------------------------------------------------

const packCmd = Command.make(
"pack",
{
appVersion: Flag.string("app-version").pipe(Flag.optional),
packDestination: Flag.string("pack-destination").pipe(Flag.optional),
verbose: Flag.boolean("verbose").pipe(Flag.withDefault(false)),
},
(config) =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const repoRoot = yield* RepoRoot;
const serverDir = path.join(repoRoot, "apps/server");
const packageJsonPath = path.join(serverDir, "package.json");
const backupPath = `${packageJsonPath}.bak`;

for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
const abs = path.join(serverDir, relPath);
if (!(yield* fs.exists(abs))) {
return yield* new CliError({
message: `Missing build asset: ${abs}. Run the build subcommand first.`,
});
}
}

const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);
const packDestination = path.resolve(
Option.getOrElse(config.packDestination, () => serverDir),
);
yield* fs
.makeDirectory(packDestination, { recursive: true })
.pipe(Effect.catch(() => Effect.void));

yield* Effect.acquireUseRelease(
Effect.gen(function* () {
const pkg: PackageJson = {
name: serverPackageJson.name,
repository: serverPackageJson.repository,
bin: serverPackageJson.bin,
type: serverPackageJson.type,
version,
engines: serverPackageJson.engines,
files: serverPackageJson.files,
dependencies: resolveCatalogDependencies(
serverPackageJson.dependencies,
rootPackageJson.workspaces.catalog,
"apps/server",
),
overrides: resolveCatalogDependencies(
rootPackageJson.overrides,
rootPackageJson.workspaces.catalog,
"apps/server",
),
};

const original = yield* fs.readFileString(packageJsonPath);
const packageJsonString = yield* encodePackageJson(pkg);
yield* fs.writeFileString(backupPath, original);
yield* fs.writeFileString(packageJsonPath, `${packageJsonString}\n`);
yield* Effect.log("[cli] Prepared package.json for pack");

const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir);
return { iconBackups };
}),
() =>
Effect.gen(function* () {
const args = ["pack", "--pack-destination", packDestination];
yield* Effect.log(`[cli] Running: npm ${args.join(" ")}`);
yield* runCommand(
ChildProcess.make("npm", args, {
cwd: serverDir,
stdout: config.verbose ? "inherit" : "ignore",
stderr: "inherit",
shell: process.platform === "win32",
}),
);

const tarballName = `${serverPackageJson.name}-${version}.tgz`;
const tarballPath = path.join(packDestination, tarballName);
if (!(yield* fs.exists(tarballPath))) {
return yield* new CliError({
message: `Expected packed tarball was not produced at ${tarballPath}`,
});
}
yield* Effect.log(`[cli] Packed tarball: ${tarballPath}`);

// Also emit a rolling alias (t3-latest.tgz) so GitHub's
// /releases/latest/download/<name> URL stays stable.
const latestAlias = path.join(packDestination, `${serverPackageJson.name}-latest.tgz`);
yield* fs.copyFile(tarballPath, latestAlias);
yield* Effect.log(`[cli] Wrote rolling alias: ${latestAlias}`);
}),
(resource: { readonly iconBackups: ReadonlyArray<PublishIconBackup> }) =>
Effect.gen(function* () {
yield* restorePublishIconOverrides(resource.iconBackups).pipe(
Effect.catch((error) =>
Effect.logError(`[cli] Failed to restore publish icon overrides: ${String(error)}`),
),
);
yield* fs.rename(backupPath, packageJsonPath);
if (config.verbose) yield* Effect.log("[cli] Restored original package.json");
}),
);
}),
).pipe(Command.withDescription("Pack the server package into a .tgz tarball (no publish)."));

// ---------------------------------------------------------------------------
// root command
// ---------------------------------------------------------------------------

const cli = Command.make("cli").pipe(
Command.withDescription("T3 server build & publish CLI."),
Command.withSubcommands([buildCmd, publishCmd]),
Command.withSubcommands([buildCmd, publishCmd, packCmd]),
);

Command.run(cli, { version: "0.0.0" }).pipe(
Expand Down
85 changes: 75 additions & 10 deletions apps/server/src/provider/Layers/PiAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import {
createAgentSession,
SessionManager,
type AgentSession,
type AgentSessionEvent,
type CreateAgentSessionOptions,

Check warning on line 6 in apps/server/src/provider/Layers/PiAdapter.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Type 'CreateAgentSessionOptions' is imported but never used.
} from "@earendil-works/pi-coding-agent";
import {
type CanonicalItemType,
EventId,
type PiSettings,
type ProviderApprovalDecision,

Check warning on line 12 in apps/server/src/provider/Layers/PiAdapter.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Type 'ProviderApprovalDecision' is imported but never used.
ProviderDriverKind,
ProviderInstanceId,
type ProviderRuntimeEvent,
type ProviderRuntimeTurnStatus,
type ProviderSendTurnInput,

Check warning on line 17 in apps/server/src/provider/Layers/PiAdapter.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Type 'ProviderSendTurnInput' is imported but never used.
type ProviderSession,
type ProviderUserInputAnswers,

Check warning on line 19 in apps/server/src/provider/Layers/PiAdapter.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Type 'ProviderUserInputAnswers' is imported but never used.
RuntimeItemId,
ThreadId,
TurnId,
Expand All @@ -41,10 +42,17 @@

const PROVIDER = ProviderDriverKind.make("piAgent");

interface PiToolItem {
readonly id: RuntimeItemId;
readonly type: CanonicalItemType;
readonly toolName: string;
readonly args: unknown;
}

interface PiTurnState {
readonly turnId: TurnId;
readonly startedAt: string;
readonly items: Array<unknown>;
readonly items: Array<PiToolItem>;
}

interface PiSessionContext {
Expand All @@ -54,7 +62,7 @@
streamFiber: Fiber.Fiber<void, never> | undefined;
readonly startedAt: string;
turnState: PiTurnState | undefined;
readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
readonly turns: Array<{ id: TurnId; items: Array<PiToolItem> }>;
stopped: boolean;
}

Expand All @@ -65,6 +73,14 @@
return fallback;
}

function readPiResumeState(resumeCursor: unknown): { sessionFile: string } | undefined {
if (!resumeCursor || typeof resumeCursor !== "object") return undefined;
const cursor = resumeCursor as Record<string, unknown>;
return typeof cursor.sessionFile === "string" && cursor.sessionFile.trim().length > 0
? { sessionFile: cursor.sessionFile }
: undefined;
}

function classifyToolItemType(toolName: string): CanonicalItemType {
const normalized = toolName.toLowerCase();
if (normalized.includes("agent") || normalized.includes("subagent")) {
Expand Down Expand Up @@ -92,6 +108,29 @@
return "dynamic_tool_call";
}

function summarizePiToolArgs(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined;
const input = args as Record<string, unknown>;

const commandValue = input.command ?? input.cmd;
if (typeof commandValue === "string" && commandValue.trim().length > 0) {
return commandValue.trim().slice(0, 400);
}

const pathValue = input.file_path ?? input.path ?? input.filePath;
if (typeof pathValue === "string" && pathValue.trim().length > 0) {
return pathValue.trim().slice(0, 400);
}

try {
const serialized = JSON.stringify(input);
if (serialized.length <= 400) return serialized;
return `${serialized.slice(0, 397)}...`;
} catch {
return undefined;
}
}

export interface PiAdapterLiveOptions {
readonly instanceId?: ProviderInstanceId;
readonly environment?: NodeJS.ProcessEnv;
Expand All @@ -103,7 +142,7 @@
) {
const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("piAgent");
const serverConfig = yield* ServerConfig;
const piEnvironment = yield* makePiEnvironment(piSettings, options?.environment);

Check warning on line 145 in apps/server/src/provider/Layers/PiAdapter.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint(no-unused-vars)

Variable 'piEnvironment' is declared but never used. Unused variables should start with a '_'.

const sessions = new Map<ThreadId, PiSessionContext>();
const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>();
Expand Down Expand Up @@ -235,10 +274,16 @@
if (!context.turnState) return;
const itemId = RuntimeItemId.make(event.toolCallId);
const itemType = classifyToolItemType(event.toolName);
const detail = summarizePiToolArgs(event.args);
const argsObj =
event.args && typeof event.args === "object"
? (event.args as Record<string, unknown>)
: undefined;
context.turnState.items.push({
id: itemId,
type: itemType,
toolName: event.toolName,
args: event.args,
});
yield* offerRuntimeEvent({
...base,
Expand All @@ -248,7 +293,8 @@
payload: {
itemType,
title: event.toolName,
...(event.args ? { detail: String(event.args) } : {}),
...(detail ? { detail } : {}),
...(argsObj ? { data: { toolName: event.toolName, input: argsObj } } : {}),
},
});
return;
Expand Down Expand Up @@ -282,6 +328,11 @@
if (!context.turnState) return;
const itemId = RuntimeItemId.make(event.toolCallId);
const itemType = classifyToolItemType(event.toolName);
const storedItem = context.turnState.items.find((item) => item.id === itemId);
const args = storedItem?.args;
const detail = summarizePiToolArgs(args);
const argsObj =
args && typeof args === "object" ? (args as Record<string, unknown>) : undefined;
yield* offerRuntimeEvent({
...base,
turnId: context.turnState.turnId,
Expand All @@ -291,15 +342,18 @@
itemType,
title: event.toolName,
status: event.isError ? "failed" : "completed",
...(detail ? { detail } : {}),
...(argsObj ? { data: { toolName: event.toolName, input: argsObj } } : {}),
},
});
return;
}

case "turn_end": {
if (context.turnState) {
yield* completeTurn(context, "completed");
}
// Pi fires turn_end after each internal LLM call, but agent_end fires
// after the full agent run. Completing here would fragment the Pi run
// into multiple t3code turns, causing tool activities to disappear and
// the timer to reset on every sub-turn. Let agent_end drive completion.
return;
}

Expand Down Expand Up @@ -438,13 +492,21 @@
const runtimeContext = yield* Effect.context<never>();
const runFork = Effect.runForkWith(runtimeContext);

const sessionOptions: CreateAgentSessionOptions = {
cwd: input.cwd ?? serverConfig.cwd,
};
const piResumeState = readPiResumeState(input.resumeCursor);
const baseCwd = input.cwd ?? serverConfig.cwd;

const piSession = yield* Effect.tryPromise({
try: async () => {
const result = await createAgentSession(sessionOptions);
if (piResumeState) {
try {
const sessionManager = SessionManager.open(piResumeState.sessionFile);
const result = await createAgentSession({ cwd: baseCwd, sessionManager });
return result.session;
} catch {
// Session file inaccessible; fall through to a fresh session.
}
}
const result = await createAgentSession({ cwd: baseCwd });
return result.session;
},
catch: (cause) =>
Expand All @@ -456,6 +518,8 @@
}),
});

const piSessionFile = piSession.sessionFile;

const session: ProviderSession = {
threadId,
provider: PROVIDER,
Expand All @@ -464,6 +528,7 @@
runtimeMode: input.runtimeMode,
...(input.cwd ? { cwd: input.cwd } : {}),
...(modelSelection?.model ? { model: modelSelection.model } : {}),
...(piSessionFile !== undefined ? { resumeCursor: { sessionFile: piSessionFile } } : {}),
createdAt: startedAt,
updatedAt: startedAt,
};
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/Layers/PiProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const runPiCommand = Effect.fn("runPiCommand")(function* (
const piEnvironment = yield* makePiEnvironment(piSettings, environment);
const command = ChildProcess.make(piSettings.binaryPath || "pi", [...args], {
env: piEnvironment,
shell: true,
shell: false,
});
return yield* spawnAndCollect(piSettings.binaryPath || "pi", command);
});
Expand Down
Loading
Loading