t3code-cli 0.10.0 → 0.11.0

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 (124) hide show
  1. package/README.md +33 -1
  2. package/dist/application.js +2 -2
  3. package/dist/auth.js +1 -1
  4. package/dist/bin.js +1566 -351
  5. package/dist/cli.js +1 -1
  6. package/dist/config.js +1 -1
  7. package/dist/connection.js +1 -11
  8. package/dist/index.js +1 -1
  9. package/dist/layout.js +1 -1
  10. package/dist/node.js +2 -0
  11. package/dist/orchestration.js +2 -2
  12. package/dist/rpc.js +2 -2
  13. package/dist/runtime.js +1 -1
  14. package/dist/scope.js +1 -1
  15. package/dist/shared.js +1740 -767
  16. package/dist/src/application/index.d.ts +3 -2
  17. package/dist/src/application/layer.d.ts +82 -841
  18. package/dist/src/application/model-selection.d.ts +15 -0
  19. package/dist/src/application/models.d.ts +1 -87
  20. package/dist/src/application/project-commands.d.ts +9 -0
  21. package/dist/src/application/projects.d.ts +13 -135
  22. package/dist/src/application/service.d.ts +84 -5
  23. package/dist/src/application/shell-sequence.d.ts +2 -12
  24. package/dist/src/application/terminals.d.ts +66 -0
  25. package/dist/src/application/thread-commands.d.ts +47 -0
  26. package/dist/src/application/thread-update.d.ts +8 -0
  27. package/dist/src/application/thread-wait.d.ts +3 -14
  28. package/dist/src/application/threads.d.ts +21 -50
  29. package/dist/src/cli/flags.d.ts +3 -0
  30. package/dist/src/cli/output-format.d.ts +2 -0
  31. package/dist/src/domain/error.d.ts +14 -1
  32. package/dist/src/domain/helpers.d.ts +0 -9
  33. package/dist/src/domain/model-config.d.ts +29 -3
  34. package/dist/src/domain/thread-lifecycle.d.ts +2 -10
  35. package/dist/src/node/index.d.ts +2 -0
  36. package/dist/src/orchestration/index.d.ts +1 -0
  37. package/dist/src/orchestration/layer.d.ts +177 -1032
  38. package/dist/src/orchestration/service.d.ts +1 -0
  39. package/dist/src/rpc/error.d.ts +4 -2
  40. package/dist/src/rpc/index.d.ts +5 -0
  41. package/dist/src/rpc/layer.d.ts +1097 -499
  42. package/dist/src/rpc/operation.d.ts +5752 -0
  43. package/dist/src/rpc/ws-group.d.ts +327 -147
  44. package/dist/src/runtime/layer.d.ts +1 -1
  45. package/dist/t3tools.js +2 -2
  46. package/dist/upstream-t3code/packages/contracts/src/environmentHttp.d.ts +10 -33
  47. package/dist/upstream-t3code/packages/contracts/src/ipc.d.ts +0 -6
  48. package/dist/upstream-t3code/packages/contracts/src/orchestration.d.ts +1 -657
  49. package/dist/upstream-t3code/packages/contracts/src/provider.d.ts +0 -14
  50. package/dist/upstream-t3code/packages/contracts/src/providerRuntime.d.ts +1 -175
  51. package/dist/upstream-t3code/packages/contracts/src/rpc.d.ts +0 -492
  52. package/dist/upstream-t3code/packages/contracts/src/server.d.ts +0 -45
  53. package/dist/upstream-t3code/packages/contracts/src/settings.d.ts +0 -21
  54. package/dist/upstream-t3code/packages/contracts/src/terminal.d.ts +3 -6
  55. package/package.json +6 -2
  56. package/src/application/index.ts +28 -2
  57. package/src/application/layer.ts +34 -5
  58. package/src/application/model-selection.ts +68 -2
  59. package/src/application/models.ts +14 -14
  60. package/src/application/project-commands.ts +15 -0
  61. package/src/application/projects.ts +43 -13
  62. package/src/application/service.ts +196 -77
  63. package/src/application/shell-sequence.ts +5 -7
  64. package/src/application/terminals.ts +207 -0
  65. package/src/application/thread-commands.test.ts +43 -0
  66. package/src/application/thread-commands.ts +81 -0
  67. package/src/application/thread-update.ts +72 -0
  68. package/src/application/thread-wait.ts +46 -47
  69. package/src/application/threads.test.ts +166 -0
  70. package/src/application/threads.ts +123 -40
  71. package/src/bin.ts +2 -0
  72. package/src/cli/app.ts +2 -0
  73. package/src/cli/confirm.ts +31 -0
  74. package/src/cli/error.ts +26 -3
  75. package/src/cli/flags.ts +15 -0
  76. package/src/cli/input/layer.ts +20 -0
  77. package/src/cli/input/service.ts +1 -0
  78. package/src/cli/output-format.ts +6 -2
  79. package/src/cli/project-format.ts +7 -0
  80. package/src/cli/project.ts +2 -1
  81. package/src/cli/projects/delete.ts +50 -0
  82. package/src/cli/self-action.ts +34 -0
  83. package/src/cli/terminal/attach.ts +32 -0
  84. package/src/cli/terminal/commands.test.ts +101 -0
  85. package/src/cli/terminal/create.ts +54 -0
  86. package/src/cli/terminal/destroy.ts +60 -0
  87. package/src/cli/terminal/encoding.test.ts +63 -0
  88. package/src/cli/terminal/encoding.ts +23 -0
  89. package/src/cli/terminal/error.ts +14 -0
  90. package/src/cli/terminal/io-node-layer.ts +82 -0
  91. package/src/cli/terminal/io-service.ts +25 -0
  92. package/src/cli/terminal/list.ts +35 -0
  93. package/src/cli/terminal/read.ts +102 -0
  94. package/src/cli/terminal/scope.ts +30 -0
  95. package/src/cli/terminal/shared.ts +250 -0
  96. package/src/cli/terminal/stream.ts +64 -0
  97. package/src/cli/terminal/wait.test.ts +146 -0
  98. package/src/cli/terminal/wait.ts +222 -0
  99. package/src/cli/terminal/write.ts +132 -0
  100. package/src/cli/terminal-format.ts +167 -0
  101. package/src/cli/terminal.ts +26 -0
  102. package/src/cli/thread-format.test.ts +32 -0
  103. package/src/cli/thread-format.ts +8 -1
  104. package/src/cli/thread.ts +8 -0
  105. package/src/cli/threads/archive.ts +11 -18
  106. package/src/cli/threads/delete.ts +61 -0
  107. package/src/cli/threads/interrupt.ts +52 -0
  108. package/src/cli/threads/list.test.ts +69 -0
  109. package/src/cli/threads/list.ts +15 -3
  110. package/src/cli/threads/send.ts +22 -2
  111. package/src/cli/threads/unarchive.ts +44 -0
  112. package/src/cli/threads/update.ts +150 -0
  113. package/src/domain/error.ts +20 -1
  114. package/src/domain/model-config.ts +4 -0
  115. package/src/domain/thread-lifecycle.ts +14 -0
  116. package/src/node/index.ts +2 -0
  117. package/src/orchestration/index.ts +1 -0
  118. package/src/orchestration/layer.ts +33 -85
  119. package/src/orchestration/service.ts +4 -0
  120. package/src/rpc/error.ts +10 -0
  121. package/src/rpc/index.ts +5 -0
  122. package/src/rpc/operation.ts +83 -0
  123. package/src/rpc/ws-group.ts +24 -0
  124. package/src/runtime/layer.ts +7 -2
package/src/cli/error.ts CHANGED
@@ -22,11 +22,20 @@ export class MissingThreadError extends Schema.TaggedErrorClass<MissingThreadErr
22
22
  },
23
23
  ) {}
24
24
 
25
- export class SelfArchiveError extends Schema.TaggedErrorClass<SelfArchiveError>()(
26
- "SelfArchiveError",
25
+ export class SelfActionError extends Schema.TaggedErrorClass<SelfActionError>()("SelfActionError", {
26
+ message: Schema.String,
27
+ threadId: Schema.String,
28
+ }) {}
29
+
30
+ export class DestructiveConfirmationRequiredError extends Schema.TaggedErrorClass<DestructiveConfirmationRequiredError>()(
31
+ "DestructiveConfirmationRequiredError",
32
+ { message: Schema.String },
33
+ ) {}
34
+
35
+ export class InvalidFlagCombinationError extends Schema.TaggedErrorClass<InvalidFlagCombinationError>()(
36
+ "InvalidFlagCombinationError",
27
37
  {
28
38
  message: Schema.String,
29
- threadId: Schema.String,
30
39
  },
31
40
  ) {}
32
41
 
@@ -36,3 +45,17 @@ export class MissingRequestError extends Schema.TaggedErrorClass<MissingRequestE
36
45
  message: Schema.String,
37
46
  },
38
47
  ) {}
48
+
49
+ export class MissingUpdateFieldsError extends Schema.TaggedErrorClass<MissingUpdateFieldsError>()(
50
+ "MissingUpdateFieldsError",
51
+ {
52
+ message: Schema.String,
53
+ },
54
+ ) {}
55
+
56
+ export class ConflictingUpdateFlagsError extends Schema.TaggedErrorClass<ConflictingUpdateFlagsError>()(
57
+ "ConflictingUpdateFlagsError",
58
+ {
59
+ message: Schema.String,
60
+ },
61
+ ) {}
package/src/cli/flags.ts CHANGED
@@ -30,6 +30,21 @@ export const projectPathFlag = Flag.string("path").pipe(
30
30
  Flag.withDefault("."),
31
31
  );
32
32
 
33
+ export const yesFlag = Flag.boolean("yes").pipe(
34
+ Flag.withDescription("Skip interactive confirmation"),
35
+ Flag.withDefault(false),
36
+ );
37
+
38
+ export const forceFlag = Flag.boolean("force").pipe(
39
+ Flag.withDescription("Delete non-empty project (cascade thread deletes)"),
40
+ Flag.withDefault(false),
41
+ );
42
+
43
+ export const selfActionForceFlag = Flag.boolean("force").pipe(
44
+ Flag.withDescription("Confirm an action targeting the calling agent thread"),
45
+ Flag.withAlias("f"),
46
+ );
47
+
33
48
  export const formatFlag = Flag.choice("format", humanJsonFormatChoices).pipe(
34
49
  Flag.withDefault("auto"),
35
50
  );
@@ -23,8 +23,28 @@ export const makeT3Input = Effect.fn("makeT3Input")(function* () {
23
23
  );
24
24
  });
25
25
 
26
+ const readStdinBinary = Effect.fn("T3InputLive.readStdinBinary")(function* () {
27
+ return yield* stdio.stdin.pipe(
28
+ Stream.runFold(
29
+ () => new Uint8Array(0),
30
+ (acc, chunk) => {
31
+ const merged = new Uint8Array(acc.length + chunk.length);
32
+ merged.set(acc);
33
+ merged.set(chunk, acc.length);
34
+ return merged;
35
+ },
36
+ ),
37
+ Effect.map((bytes) => Buffer.from(bytes).toString("latin1")),
38
+ Effect.catchTags({
39
+ PlatformError: (error) =>
40
+ Effect.fail(new InputError({ message: "failed to read stdin", cause: error })),
41
+ }),
42
+ );
43
+ });
44
+
26
45
  return {
27
46
  readStdin,
47
+ readStdinBinary,
28
48
  };
29
49
  });
30
50
 
@@ -7,5 +7,6 @@ export class T3Input extends Context.Service<
7
7
  T3Input,
8
8
  {
9
9
  readonly readStdin: () => Effect.Effect<string, InputError>;
10
+ readonly readStdinBinary: () => Effect.Effect<string, InputError>;
10
11
  }
11
12
  >()("t3cli/T3Input") {}
@@ -25,13 +25,17 @@ export function canRenderLiveTerminal(environment: EnvironmentShape) {
25
25
  );
26
26
  }
27
27
 
28
- function isHumanTerminal(environment: EnvironmentShape) {
28
+ export function isInteractiveHumanTerminal(environment: EnvironmentShape) {
29
29
  return (
30
30
  environment.stdoutIsTTY && !isAgentEnvironment(environment) && environment.env.TERM !== "dumb"
31
31
  );
32
32
  }
33
33
 
34
- function isAgentEnvironment(environment: EnvironmentShape) {
34
+ function isHumanTerminal(environment: EnvironmentShape) {
35
+ return isInteractiveHumanTerminal(environment);
36
+ }
37
+
38
+ export function isAgentEnvironment(environment: EnvironmentShape) {
35
39
  return (
36
40
  environment.env.CI !== undefined ||
37
41
  environment.env.CODEX_CI !== undefined ||
@@ -9,3 +9,10 @@ export function formatProjectsHuman(projects: ReadonlyArray<OrchestrationProject
9
9
  export function formatProjectAddedHuman(project: OrchestrationProjectShell) {
10
10
  return `project added: ${project.title}\nid: ${project.id}\npath: ${project.workspaceRoot}`;
11
11
  }
12
+
13
+ export function formatProjectDeletedHuman(input: {
14
+ readonly projectId: string;
15
+ readonly dispatch: { readonly sequence: number };
16
+ }) {
17
+ return `project deleted: ${input.projectId}\nsequence: ${input.dispatch.sequence}`;
18
+ }
@@ -4,6 +4,7 @@ import { Command, Flag } from "effect/unstable/cli";
4
4
 
5
5
  import { formatFlag, projectPathFlag } from "./flags.ts";
6
6
  import { formatProjectAddedHuman, formatProjectsHuman } from "./project-format.ts";
7
+ import { deleteProjectCommand } from "./projects/delete.ts";
7
8
  import { T3Application } from "../application/service.ts";
8
9
  import { Environment } from "../environment/service.ts";
9
10
  import { resolveOutputFormat } from "./output-format.ts";
@@ -12,7 +13,7 @@ import { T3Output } from "./output/service.ts";
12
13
  export function createProjectCommand() {
13
14
  return Command.make("project").pipe(
14
15
  Command.withDescription("project commands"),
15
- Command.withSubcommands([listCommand, addCommand]),
16
+ Command.withSubcommands([listCommand, addCommand, deleteProjectCommand]),
16
17
  );
17
18
  }
18
19
 
@@ -0,0 +1,50 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { Command } from "effect/unstable/cli";
3
+
4
+ import { requireDestructiveConfirmation } from "../confirm.ts";
5
+ import { forceFlag, formatFlag, projectFlag, yesFlag } from "../flags.ts";
6
+ import { formatProjectDeletedHuman } from "../project-format.ts";
7
+ import { requireCommandProjectRef } from "../require.ts";
8
+ import { T3Application } from "../../application/service.ts";
9
+ import { Environment } from "../../environment/service.ts";
10
+ import { resolveOutputFormat } from "../output-format.ts";
11
+ import { T3Output } from "../output/service.ts";
12
+
13
+ export const deleteProjectCommand = Command.make(
14
+ "delete",
15
+ {
16
+ project: projectFlag,
17
+ force: forceFlag,
18
+ yes: yesFlag,
19
+ format: formatFlag,
20
+ },
21
+ ({ project, force, yes, format }) =>
22
+ Effect.gen(function* () {
23
+ const application = yield* T3Application;
24
+ const environment = yield* Environment;
25
+ const output = yield* T3Output;
26
+ const projectRef = yield* requireCommandProjectRef({
27
+ project,
28
+ env: environment.env,
29
+ cwd: environment.cwd,
30
+ });
31
+ const resolvedProject = yield* application.resolveProject(projectRef);
32
+ yield* requireDestructiveConfirmation({
33
+ message: `Delete project ${resolvedProject.title} (${resolvedProject.id})?`,
34
+ yes,
35
+ environment,
36
+ });
37
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
38
+ const result = yield* application.deleteProject({
39
+ projectId: resolvedProject.id,
40
+ ...(force ? { force: true } : {}),
41
+ });
42
+ if (resolvedFormat === "json") {
43
+ return yield* output.printJson({
44
+ projectId: result.projectId,
45
+ dispatch: result.dispatch,
46
+ });
47
+ }
48
+ return yield* output.printInfo(formatProjectDeletedHuman(result));
49
+ }),
50
+ ).pipe(Command.withDescription("delete project"));
@@ -0,0 +1,34 @@
1
+ import * as Effect from "effect/Effect";
2
+
3
+ import { SelfActionError } from "./error.ts";
4
+ import { isAgentEnvironment } from "./output-format.ts";
5
+ import type { EnvironmentShape } from "../environment/service.ts";
6
+
7
+ export const requireSelfActionConfirmation = Effect.fn("requireSelfActionConfirmation")(
8
+ function* (input: {
9
+ readonly threadId: string;
10
+ readonly force: boolean;
11
+ readonly environment: EnvironmentShape;
12
+ readonly action: string;
13
+ }) {
14
+ if (input.force) {
15
+ return;
16
+ }
17
+ if (!isAgentEnvironment(input.environment)) {
18
+ return;
19
+ }
20
+ const callerThreadId = input.environment.env.T3CODE_THREAD_ID;
21
+ if (callerThreadId === undefined || callerThreadId.length === 0) {
22
+ return;
23
+ }
24
+ if (input.threadId !== callerThreadId) {
25
+ return;
26
+ }
27
+ yield* Effect.fail(
28
+ new SelfActionError({
29
+ threadId: input.threadId,
30
+ message: `cannot ${input.action} thread ${input.threadId}: command targets the calling thread. Pass --force to confirm.`,
31
+ }),
32
+ );
33
+ },
34
+ );
@@ -0,0 +1,32 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { Argument, Command } from "effect/unstable/cli";
3
+
4
+ import { T3Application } from "../../application/service.ts";
5
+ import { Environment } from "../../environment/service.ts";
6
+ import { threadFlag } from "../flags.ts";
7
+ import { requireCommandThreadId } from "./scope.ts";
8
+ import { runAttachedTerminalSession, toTerminalAttachTarget } from "./shared.ts";
9
+
10
+ export const attachTerminalCommand = Command.make(
11
+ "attach",
12
+ {
13
+ thread: threadFlag,
14
+ terminalId: Argument.string("terminal-id"),
15
+ },
16
+ ({ thread, terminalId }) =>
17
+ Effect.gen(function* () {
18
+ const application = yield* T3Application;
19
+ const environment = yield* Environment;
20
+ const threadId = yield* requireCommandThreadId({
21
+ thread,
22
+ env: environment.env,
23
+ });
24
+ const terminal = yield* application.getTerminal({
25
+ threadId,
26
+ terminalId,
27
+ });
28
+ yield* runAttachedTerminalSession({
29
+ terminal: toTerminalAttachTarget(terminal),
30
+ });
31
+ }),
32
+ ).pipe(Command.withDescription("attach to a terminal"));
@@ -0,0 +1,101 @@
1
+ import "vite-plus/test/config";
2
+
3
+ import * as Cause from "effect/Cause";
4
+ import * as Effect from "effect/Effect";
5
+ import * as Exit from "effect/Exit";
6
+ import * as Layer from "effect/Layer";
7
+ import * as NodeServices from "@effect/platform-node/NodeServices";
8
+ import * as Option from "effect/Option";
9
+ import { assert, describe, it } from "@effect/vitest";
10
+ import { Command } from "effect/unstable/cli";
11
+ import { fromPartial } from "@total-typescript/shoehorn";
12
+
13
+ import { T3Application } from "../../application/service.ts";
14
+ import { T3Config } from "../../config/service.ts";
15
+ import { NodeEnvironmentLive } from "../../environment/layer.ts";
16
+ import { InvalidFlagCombinationError } from "../error.ts";
17
+ import { T3Input } from "../input/service.ts";
18
+ import { T3Output } from "../output/service.ts";
19
+ import { readTerminalCommand } from "./read.ts";
20
+ import { writeTerminalCommand } from "./write.ts";
21
+
22
+ const testLayer = Layer.mergeAll(
23
+ Layer.succeed(
24
+ T3Application,
25
+ fromPartial({
26
+ getTerminal: () => Effect.die("getTerminal should not be called"),
27
+ attachTerminal: () => Effect.die("attachTerminal should not be called"),
28
+ writeTerminal: () => Effect.die("writeTerminal should not be called"),
29
+ }),
30
+ ),
31
+ Layer.succeed(
32
+ T3Config,
33
+ fromPartial({
34
+ resolve: () =>
35
+ Effect.succeed({
36
+ url: "ws://localhost",
37
+ token: "token",
38
+ source: "config",
39
+ local: true,
40
+ }),
41
+ }),
42
+ ),
43
+ Layer.succeed(T3Output, {
44
+ writeStdout: () => Effect.void,
45
+ writeStderr: () => Effect.void,
46
+ printJson: () => Effect.void,
47
+ printNdjson: () => Effect.void,
48
+ printInfo: () => Effect.void,
49
+ }),
50
+ Layer.succeed(T3Input, {
51
+ readStdin: () => Effect.die("readStdin should not be called"),
52
+ readStdinBinary: () => Effect.die("readStdinBinary should not be called"),
53
+ }),
54
+ NodeServices.layer,
55
+ NodeEnvironmentLive,
56
+ );
57
+
58
+ describe("readTerminalCommand", () => {
59
+ it.layer(testLayer)("readTerminalCommand", (t) => {
60
+ t.effect("rejects --from-sequence without --follow", () =>
61
+ Effect.gen(function* () {
62
+ const run = Command.runWith(readTerminalCommand, { version: "0.0.0-test" });
63
+ const exit = yield* run(["--thread", "thread-1", "term-1", "--from-sequence", "1"]).pipe(
64
+ Effect.exit,
65
+ );
66
+ assert.isTrue(Exit.isFailure(exit));
67
+ if (!Exit.isFailure(exit)) {
68
+ return;
69
+ }
70
+ const error = Cause.findErrorOption(exit.cause);
71
+ assert.isTrue(Option.isSome(error));
72
+ if (Option.isSome(error)) {
73
+ assert.instanceOf(error.value, InvalidFlagCombinationError);
74
+ assert.equal(error.value.message, "--from-sequence requires --follow");
75
+ }
76
+ }),
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("writeTerminalCommand", () => {
82
+ it.layer(testLayer)("writeTerminalCommand", (t) => {
83
+ t.effect("rejects multiple payload sources", () =>
84
+ Effect.gen(function* () {
85
+ const run = Command.runWith(writeTerminalCommand, { version: "0.0.0-test" });
86
+ const exit = yield* run(["--thread", "thread-1", "term-1", "hello", "--stdin"]).pipe(
87
+ Effect.exit,
88
+ );
89
+ assert.isTrue(Exit.isFailure(exit));
90
+ if (!Exit.isFailure(exit)) {
91
+ return;
92
+ }
93
+ const error = Cause.findErrorOption(exit.cause);
94
+ assert.isTrue(Option.isSome(error));
95
+ if (Option.isSome(error)) {
96
+ assert.instanceOf(error.value, InvalidFlagCombinationError);
97
+ }
98
+ }),
99
+ );
100
+ });
101
+ });
@@ -0,0 +1,54 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import { Argument, Command, Flag } from "effect/unstable/cli";
4
+
5
+ import { formatTerminalCreatedHuman } from "../terminal-format.ts";
6
+ import { T3Application } from "../../application/service.ts";
7
+ import { Environment } from "../../environment/service.ts";
8
+ import { formatFlag, threadFlag } from "../flags.ts";
9
+ import { resolveOutputFormat } from "../output-format.ts";
10
+ import { T3Output } from "../output/service.ts";
11
+ import { requireCommandThreadId } from "./scope.ts";
12
+ import { runAttachedTerminalSession, snapshotToTerminalAttachTarget } from "./shared.ts";
13
+
14
+ export const createTerminalCommand = Command.make(
15
+ "create",
16
+ {
17
+ thread: threadFlag,
18
+ command: Argument.string("command").pipe(Argument.optional),
19
+ id: Flag.string("id").pipe(Flag.optional),
20
+ attach: Flag.boolean("attach"),
21
+ format: formatFlag,
22
+ },
23
+ ({ thread, command, id, attach, format }) =>
24
+ Effect.gen(function* () {
25
+ const application = yield* T3Application;
26
+ const environment = yield* Environment;
27
+ const output = yield* T3Output;
28
+ const threadId = yield* requireCommandThreadId({
29
+ thread,
30
+ env: environment.env,
31
+ });
32
+ const terminalId = Option.getOrUndefined(id);
33
+ const commandValue = Option.getOrUndefined(command);
34
+ const snapshot = yield* application.createTerminal({
35
+ threadId,
36
+ ...(terminalId !== undefined ? { terminalId } : {}),
37
+ ...(commandValue !== undefined ? { command: commandValue } : {}),
38
+ });
39
+
40
+ if (attach) {
41
+ yield* runAttachedTerminalSession({
42
+ terminal: snapshotToTerminalAttachTarget(snapshot),
43
+ });
44
+ return;
45
+ }
46
+
47
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
48
+ if (resolvedFormat === "json") {
49
+ yield* output.printJson(snapshot);
50
+ } else {
51
+ yield* output.printInfo(formatTerminalCreatedHuman(snapshot));
52
+ }
53
+ }),
54
+ ).pipe(Command.withDescription("create a terminal for a thread"));
@@ -0,0 +1,60 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { Argument, Command, Flag } from "effect/unstable/cli";
3
+
4
+ import { requireDestructiveConfirmation } from "../confirm.ts";
5
+ import { formatTerminalDestroyedHuman } from "../terminal-format.ts";
6
+ import { T3Application } from "../../application/service.ts";
7
+ import { Environment } from "../../environment/service.ts";
8
+ import { formatFlag, threadFlag, yesFlag } from "../flags.ts";
9
+ import { resolveOutputFormat } from "../output-format.ts";
10
+ import { T3Output } from "../output/service.ts";
11
+ import { requireCommandThreadId } from "./scope.ts";
12
+
13
+ export const destroyTerminalCommand = Command.make(
14
+ "destroy",
15
+ {
16
+ thread: threadFlag,
17
+ terminalId: Argument.string("terminal-id"),
18
+ quiet: Flag.boolean("quiet"),
19
+ yes: yesFlag,
20
+ format: formatFlag,
21
+ },
22
+ ({ thread, terminalId, quiet, yes, format }) =>
23
+ Effect.gen(function* () {
24
+ const application = yield* T3Application;
25
+ const environment = yield* Environment;
26
+ const output = yield* T3Output;
27
+ const threadId = yield* requireCommandThreadId({
28
+ thread,
29
+ env: environment.env,
30
+ });
31
+ yield* requireDestructiveConfirmation({
32
+ message: `Destroy terminal ${terminalId} in thread ${threadId} and delete its history?`,
33
+ yes,
34
+ environment,
35
+ });
36
+ const terminal = {
37
+ threadId,
38
+ terminalId,
39
+ };
40
+ yield* application.destroyTerminal(terminal);
41
+ if (quiet) {
42
+ return;
43
+ }
44
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
45
+ if (resolvedFormat === "json") {
46
+ yield* output.printJson({
47
+ threadId: terminal.threadId,
48
+ terminalId: terminal.terminalId,
49
+ destroyed: true,
50
+ });
51
+ return;
52
+ }
53
+ yield* output.printInfo(
54
+ formatTerminalDestroyedHuman({
55
+ terminalId,
56
+ threadId,
57
+ }),
58
+ );
59
+ }),
60
+ ).pipe(Command.withDescription("destroy a terminal and its history"));
@@ -0,0 +1,63 @@
1
+ import "vite-plus/test/config";
2
+
3
+ import { describe, expect, it } from "vite-plus/test";
4
+
5
+ import { decodeBase64Payload, decodeHexPayload } from "./encoding.ts";
6
+ import { filterAttachStreamEvent } from "./shared.ts";
7
+
8
+ describe("terminal encoding", () => {
9
+ it("preserves non-utf8 bytes in hex payloads", () => {
10
+ expect(decodeHexPayload("ff")).toBe("\xff");
11
+ });
12
+
13
+ it("preserves non-utf8 bytes in base64 payloads", () => {
14
+ expect(decodeBase64Payload("/w==")).toBe("\xff");
15
+ });
16
+ });
17
+
18
+ describe("filterAttachStreamEvent", () => {
19
+ const snapshotEvent = {
20
+ type: "snapshot" as const,
21
+ snapshot: {
22
+ threadId: "thread-1",
23
+ terminalId: "term-1",
24
+ cwd: "/tmp",
25
+ worktreePath: null,
26
+ status: "running" as const,
27
+ pid: 1,
28
+ history: "hello",
29
+ exitCode: null,
30
+ exitSignal: null,
31
+ label: "shell",
32
+ updatedAt: "2026-01-01T00:00:00.000Z",
33
+ sequence: 3,
34
+ },
35
+ };
36
+
37
+ it("keeps snapshot history when --history is set with --from-sequence", () => {
38
+ const filtered = filterAttachStreamEvent(snapshotEvent, {
39
+ includeHistory: true,
40
+ fromSequence: 5,
41
+ });
42
+ expect(filtered?.type).toBe("snapshot");
43
+ if (filtered?.type === "snapshot") {
44
+ expect(filtered.snapshot.history).toBe("hello");
45
+ }
46
+ });
47
+
48
+ it("filters sequenced events inclusively from --from-sequence", () => {
49
+ const outputEvent = {
50
+ type: "output" as const,
51
+ threadId: "thread-1",
52
+ terminalId: "term-1",
53
+ sequence: 5,
54
+ data: "x",
55
+ };
56
+ expect(
57
+ filterAttachStreamEvent(outputEvent, { includeHistory: false, fromSequence: 5 }),
58
+ ).toEqual(outputEvent);
59
+ expect(
60
+ filterAttachStreamEvent(outputEvent, { includeHistory: false, fromSequence: 6 }),
61
+ ).toBeNull();
62
+ });
63
+ });
@@ -0,0 +1,23 @@
1
+ export function bytesToLatin1String(bytes: Uint8Array): string {
2
+ return Buffer.from(bytes).toString("latin1");
3
+ }
4
+
5
+ export function decodeHexPayload(value: string): string | undefined {
6
+ const normalized = value.trim();
7
+ if (
8
+ normalized.length === 0 ||
9
+ normalized.length % 2 !== 0 ||
10
+ !/^[0-9a-fA-F]+$/.test(normalized)
11
+ ) {
12
+ return undefined;
13
+ }
14
+ return Buffer.from(normalized, "hex").toString("latin1");
15
+ }
16
+
17
+ export function decodeBase64Payload(value: string): string | undefined {
18
+ const normalized = value.trim();
19
+ if (normalized.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) {
20
+ return undefined;
21
+ }
22
+ return Buffer.from(normalized, "base64").toString("latin1");
23
+ }
@@ -0,0 +1,14 @@
1
+ import * as Schema from "effect/Schema";
2
+
3
+ export class TerminalCliError extends Schema.TaggedErrorClass<TerminalCliError>()(
4
+ "TerminalCliError",
5
+ {
6
+ message: Schema.String,
7
+ threadId: Schema.optionalKey(Schema.String),
8
+ terminalId: Schema.optionalKey(Schema.String),
9
+ },
10
+ ) {}
11
+
12
+ export class TerminalIoError extends Schema.TaggedErrorClass<TerminalIoError>()("TerminalIoError", {
13
+ message: Schema.String,
14
+ }) {}
@@ -0,0 +1,82 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Layer from "effect/Layer";
3
+ import * as Queue from "effect/Queue";
4
+ import * as Stream from "effect/Stream";
5
+
6
+ import { TerminalIoError } from "./error.ts";
7
+ import { TerminalIo, type RawTerminalIoSession, type TerminalWindowSize } from "./io-service.ts";
8
+
9
+ const DEFAULT_COLS = 80;
10
+ const DEFAULT_ROWS = 24;
11
+ const INTERACTIVE_TTY_MESSAGE = "terminal attach requires an interactive TTY on stdin and stdout";
12
+
13
+ const requireInteractiveTty = Effect.gen(function* () {
14
+ if (!process.stdin.isTTY || !process.stdout.isTTY || process.stdin.setRawMode === undefined) {
15
+ return yield* Effect.fail(new TerminalIoError({ message: INTERACTIVE_TTY_MESSAGE }));
16
+ }
17
+
18
+ return {
19
+ stdin: process.stdin,
20
+ stdout: process.stdout,
21
+ };
22
+ });
23
+
24
+ export const NodeTerminalIoLive = Layer.succeed(TerminalIo, {
25
+ getWindowSize: requireInteractiveTty.pipe(Effect.map(readWindowSize)),
26
+ writeOutput: (text: string) =>
27
+ Effect.sync(() => {
28
+ process.stdout.write(text);
29
+ }),
30
+ openRawSession: Effect.acquireRelease(
31
+ Effect.gen(function* () {
32
+ const tty = yield* requireInteractiveTty;
33
+ const input = yield* Queue.unbounded<Uint8Array>();
34
+ const resize = yield* Queue.unbounded<TerminalWindowSize>();
35
+
36
+ const onResize = () => {
37
+ Effect.runFork(Queue.offer(resize, readWindowSize(tty)).pipe(Effect.ignore));
38
+ };
39
+
40
+ const onData = (chunk: Buffer | string) => {
41
+ Effect.runFork(
42
+ Queue.offer(input, typeof chunk === "string" ? Buffer.from(chunk) : chunk).pipe(
43
+ Effect.ignore,
44
+ ),
45
+ );
46
+ };
47
+
48
+ tty.stdin.resume();
49
+ tty.stdin.setRawMode(true);
50
+ process.on("SIGWINCH", onResize);
51
+ tty.stdin.on("data", onData);
52
+
53
+ return {
54
+ tty,
55
+ input,
56
+ resize,
57
+ onData,
58
+ onResize,
59
+ session: {
60
+ input: Stream.fromQueue(input),
61
+ resize: Stream.fromQueue(resize),
62
+ } satisfies RawTerminalIoSession,
63
+ };
64
+ }),
65
+ ({ tty, input, resize, onData, onResize }) =>
66
+ Effect.gen(function* () {
67
+ tty.stdin.off("data", onData);
68
+ process.off("SIGWINCH", onResize);
69
+ tty.stdin.setRawMode(false);
70
+ tty.stdin.pause();
71
+ yield* Queue.shutdown(input);
72
+ yield* Queue.shutdown(resize);
73
+ }),
74
+ ).pipe(Effect.map(({ session }) => session)),
75
+ });
76
+
77
+ function readWindowSize(tty: { readonly stdout: NodeJS.WriteStream }): TerminalWindowSize {
78
+ return {
79
+ cols: tty.stdout.columns ?? DEFAULT_COLS,
80
+ rows: tty.stdout.rows ?? DEFAULT_ROWS,
81
+ };
82
+ }