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
@@ -0,0 +1,25 @@
1
+ import * as Context from "effect/Context";
2
+ import type * as Effect from "effect/Effect";
3
+ import type * as Scope from "effect/Scope";
4
+ import type * as Stream from "effect/Stream";
5
+
6
+ import type { TerminalIoError } from "./error.ts";
7
+
8
+ export type TerminalWindowSize = {
9
+ readonly cols: number;
10
+ readonly rows: number;
11
+ };
12
+
13
+ export type RawTerminalIoSession = {
14
+ readonly input: Stream.Stream<Uint8Array>;
15
+ readonly resize: Stream.Stream<TerminalWindowSize>;
16
+ };
17
+
18
+ export class TerminalIo extends Context.Service<
19
+ TerminalIo,
20
+ {
21
+ readonly getWindowSize: Effect.Effect<TerminalWindowSize, TerminalIoError>;
22
+ readonly writeOutput: (text: string) => Effect.Effect<void>;
23
+ readonly openRawSession: Effect.Effect<RawTerminalIoSession, TerminalIoError, Scope.Scope>;
24
+ }
25
+ >()("t3cli/T3TerminalIo") {}
@@ -0,0 +1,35 @@
1
+ import * as Effect from "effect/Effect";
2
+ import { Command } from "effect/unstable/cli";
3
+
4
+ import { formatTerminalListHuman } from "../terminal-format.ts";
5
+ import { T3Application } from "../../application/service.ts";
6
+ import { Environment } from "../../environment/service.ts";
7
+ import { formatFlag, threadFlag } from "../flags.ts";
8
+ import { resolveOutputFormat } from "../output-format.ts";
9
+ import { T3Output } from "../output/service.ts";
10
+ import { requireCommandThreadId } from "./scope.ts";
11
+
12
+ export const listTerminalCommand = Command.make(
13
+ "list",
14
+ {
15
+ thread: threadFlag,
16
+ format: formatFlag,
17
+ },
18
+ ({ thread, format }) =>
19
+ Effect.gen(function* () {
20
+ const application = yield* T3Application;
21
+ const environment = yield* Environment;
22
+ const output = yield* T3Output;
23
+ const threadId = yield* requireCommandThreadId({
24
+ thread,
25
+ env: environment.env,
26
+ });
27
+ const resolvedFormat = resolveOutputFormat(format, environment, "json");
28
+ const terminals = yield* application.listTerminals(threadId);
29
+ if (resolvedFormat === "json") {
30
+ yield* output.printJson(terminals);
31
+ } else {
32
+ yield* output.writeStdout(formatTerminalListHuman(terminals));
33
+ }
34
+ }),
35
+ ).pipe(Command.withDescription("list terminals for a thread"));
@@ -0,0 +1,102 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Stream from "effect/Stream";
4
+ import { Argument, Command, Flag } from "effect/unstable/cli";
5
+
6
+ import { T3Application } from "../../application/service.ts";
7
+ import { Environment } from "../../environment/service.ts";
8
+ import { InvalidFlagCombinationError, InvalidLimitError } from "../error.ts";
9
+ import { threadFlag } from "../flags.ts";
10
+ import { T3Output } from "../output/service.ts";
11
+ import { TerminalCliError } from "./error.ts";
12
+ import { requireCommandThreadId } from "./scope.ts";
13
+ import { filterAttachStreamEvent, toTerminalAttachTarget } from "./shared.ts";
14
+
15
+ const jsonNdjsonFormatChoices = ["json", "ndjson"] as const;
16
+
17
+ export const readTerminalCommand = Command.make(
18
+ "read",
19
+ {
20
+ thread: threadFlag,
21
+ terminalId: Argument.string("terminal-id"),
22
+ history: Flag.boolean("history"),
23
+ follow: Flag.boolean("follow"),
24
+ fromSequence: Flag.integer("from-sequence").pipe(Flag.optional),
25
+ format: Flag.choice("format", jsonNdjsonFormatChoices).pipe(Flag.withDefault("json")),
26
+ },
27
+ ({ thread, terminalId, history, follow, fromSequence, format }) =>
28
+ Effect.gen(function* () {
29
+ const output = yield* T3Output;
30
+ const application = yield* T3Application;
31
+ const environment = yield* Environment;
32
+ const threadId = yield* requireCommandThreadId({
33
+ thread,
34
+ env: environment.env,
35
+ });
36
+ const fromSequenceValue = Option.getOrUndefined(fromSequence);
37
+
38
+ if (fromSequenceValue !== undefined && fromSequenceValue < 0) {
39
+ yield* Effect.fail(
40
+ new InvalidLimitError({
41
+ message: "from-sequence must be a non-negative integer",
42
+ value: String(fromSequenceValue),
43
+ }),
44
+ );
45
+ }
46
+ if (fromSequenceValue !== undefined && !follow) {
47
+ yield* Effect.fail(
48
+ new InvalidFlagCombinationError({
49
+ message: "--from-sequence requires --follow",
50
+ }),
51
+ );
52
+ }
53
+ if (follow && format !== "ndjson") {
54
+ yield* Effect.fail(
55
+ new InvalidFlagCombinationError({
56
+ message: "--follow requires --format ndjson",
57
+ }),
58
+ );
59
+ }
60
+
61
+ const terminal = yield* application.getTerminal({
62
+ threadId,
63
+ terminalId,
64
+ });
65
+ const stream = application
66
+ .attachTerminal({
67
+ terminal: toTerminalAttachTarget(terminal),
68
+ })
69
+ .pipe(
70
+ Stream.map((event) =>
71
+ filterAttachStreamEvent(event, {
72
+ includeHistory: history,
73
+ ...(fromSequenceValue !== undefined ? { fromSequence: fromSequenceValue } : {}),
74
+ }),
75
+ ),
76
+ Stream.filter((event): event is NonNullable<typeof event> => event !== null),
77
+ );
78
+
79
+ if (follow) {
80
+ yield* Stream.runForEach(stream, (event) => output.printNdjson(event));
81
+ return;
82
+ }
83
+
84
+ const item = yield* Stream.runHead(stream);
85
+ const event = Option.getOrUndefined(item);
86
+ if (event === undefined || event.type !== "snapshot") {
87
+ yield* Effect.fail(
88
+ new TerminalCliError({
89
+ message: "server did not return terminal snapshot",
90
+ threadId,
91
+ terminalId,
92
+ }),
93
+ );
94
+ } else {
95
+ if (format === "ndjson") {
96
+ yield* output.printNdjson(event);
97
+ } else {
98
+ yield* output.printJson(event.snapshot);
99
+ }
100
+ }
101
+ }),
102
+ ).pipe(Command.withDescription("read terminal state and optional history"));
@@ -0,0 +1,30 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+
4
+ import { resolveThreadId } from "../../scope/index.ts";
5
+ import { MissingThreadError } from "../error.ts";
6
+
7
+ export function resolveCommandThreadId(input: {
8
+ readonly thread: Option.Option<string>;
9
+ readonly env: Readonly<Record<string, string | undefined>>;
10
+ }): string | undefined {
11
+ return resolveThreadId({
12
+ value: Option.getOrUndefined(input.thread),
13
+ env: input.env,
14
+ });
15
+ }
16
+
17
+ export const requireCommandThreadId = Effect.fn("requireCommandThreadId")(function* (input: {
18
+ readonly thread: Option.Option<string>;
19
+ readonly env: Readonly<Record<string, string | undefined>>;
20
+ }) {
21
+ const threadId = resolveCommandThreadId(input);
22
+ if (threadId === undefined) {
23
+ return yield* Effect.fail(
24
+ new MissingThreadError({
25
+ message: "thread id is required: pass --thread or set T3CODE_THREAD_ID",
26
+ }),
27
+ );
28
+ }
29
+ return threadId;
30
+ });
@@ -0,0 +1,250 @@
1
+ import * as Deferred from "effect/Deferred";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Match from "effect/Match";
4
+ import * as Stream from "effect/Stream";
5
+ import type {
6
+ TerminalAttachStreamEvent,
7
+ TerminalSessionSnapshot,
8
+ TerminalSummary,
9
+ } from "#t3tools/contracts";
10
+
11
+ import type { TerminalAttachTarget, TerminalRef } from "../../application/service.ts";
12
+ import { T3Application } from "../../application/service.ts";
13
+ import type { ApplicationError } from "../../application/error.ts";
14
+ import { bytesToLatin1String } from "./encoding.ts";
15
+ import { TerminalCliError, type TerminalIoError } from "./error.ts";
16
+ import { TerminalIo } from "./io-service.ts";
17
+
18
+ const DETACH_BYTE = 0x1d;
19
+ const ANSI_CLEAR_SCREEN = "\u001bc";
20
+
21
+ type AttachSessionResult =
22
+ | {
23
+ readonly _tag: "Failure";
24
+ readonly error: ApplicationError | TerminalCliError;
25
+ readonly message?: string;
26
+ }
27
+ | {
28
+ readonly _tag: "Success";
29
+ readonly message?: string;
30
+ };
31
+
32
+ export function runAttachedTerminalSession(input: { readonly terminal: TerminalAttachTarget }) {
33
+ return Effect.scoped(
34
+ Effect.gen(function* () {
35
+ const application = yield* T3Application;
36
+ const io = yield* TerminalIo;
37
+ const { cols, rows } = yield* io.getWindowSize.pipe(
38
+ Effect.mapError((error) => mapTerminalIoError(error, input.terminal)),
39
+ );
40
+ const session = yield* io.openRawSession.pipe(
41
+ Effect.mapError((error) => mapTerminalIoError(error, input.terminal)),
42
+ );
43
+ const completion = yield* Deferred.make<AttachSessionResult>();
44
+ const stream = application.attachTerminal({
45
+ terminal: input.terminal,
46
+ cols,
47
+ rows,
48
+ });
49
+
50
+ yield* Stream.runForEach(stream, applyAttachEvent).pipe(
51
+ Effect.match({
52
+ onFailure: (error) => completeAttachSession(completion, { _tag: "Failure", error }),
53
+ onSuccess: () => completeAttachSession(completion, { _tag: "Success" }),
54
+ }),
55
+ Effect.forkScoped,
56
+ );
57
+
58
+ yield* Stream.runForEach(session.resize, ({ cols: nextCols, rows: nextRows }) =>
59
+ application.resizeTerminal({
60
+ terminal: input.terminal,
61
+ cols: nextCols,
62
+ rows: nextRows,
63
+ }),
64
+ ).pipe(
65
+ Effect.match({
66
+ onFailure: (error) => completeAttachSession(completion, { _tag: "Failure", error }),
67
+ onSuccess: () => completeAttachSession(completion, { _tag: "Success" }),
68
+ }),
69
+ Effect.forkScoped,
70
+ );
71
+
72
+ yield* Stream.runForEach(session.input, (chunk) =>
73
+ handleSessionInput({
74
+ completion,
75
+ terminal: input.terminal,
76
+ chunk,
77
+ }),
78
+ ).pipe(
79
+ Effect.match({
80
+ onFailure: (error) => completeAttachSession(completion, { _tag: "Failure", error }),
81
+ onSuccess: () => completeAttachSession(completion, { _tag: "Success" }),
82
+ }),
83
+ Effect.forkScoped,
84
+ );
85
+
86
+ const result = yield* Deferred.await(completion);
87
+ if (result.message !== undefined) {
88
+ yield* writeSystemMessage(result.message);
89
+ }
90
+ return yield* Match.value(result).pipe(
91
+ Match.tag("Failure", ({ error }) => Effect.fail(error)),
92
+ Match.orElse(() => Effect.void),
93
+ );
94
+ }),
95
+ );
96
+ }
97
+
98
+ function completeAttachSession(
99
+ completion: Deferred.Deferred<AttachSessionResult>,
100
+ result: AttachSessionResult,
101
+ ) {
102
+ return Deferred.succeed(completion, result).pipe(Effect.ignore);
103
+ }
104
+
105
+ function handleSessionInput(input: {
106
+ readonly completion: Deferred.Deferred<AttachSessionResult>;
107
+ readonly terminal: TerminalRef;
108
+ readonly chunk: Uint8Array;
109
+ }) {
110
+ const detachOffset = input.chunk.indexOf(DETACH_BYTE);
111
+ const payload = detachOffset === -1 ? input.chunk : input.chunk.slice(0, detachOffset);
112
+
113
+ if (payload.length === 0 && detachOffset === -1) {
114
+ return Effect.void;
115
+ }
116
+
117
+ return Effect.gen(function* () {
118
+ const application = yield* T3Application;
119
+ if (payload.length > 0) {
120
+ yield* application.writeTerminal({
121
+ terminal: input.terminal,
122
+ data: bytesToLatin1String(payload),
123
+ });
124
+ }
125
+
126
+ if (detachOffset !== -1) {
127
+ yield* completeAttachSession(input.completion, {
128
+ _tag: "Success",
129
+ message: "detached",
130
+ });
131
+ }
132
+ });
133
+ }
134
+
135
+ function mapTerminalIoError(
136
+ error: TerminalIoError,
137
+ terminal: TerminalAttachTarget,
138
+ ): TerminalCliError {
139
+ return new TerminalCliError({
140
+ message: error.message,
141
+ threadId: terminal.threadId,
142
+ terminalId: terminal.terminalId,
143
+ });
144
+ }
145
+
146
+ function applyAttachEvent(event: TerminalAttachStreamEvent) {
147
+ return Effect.gen(function* () {
148
+ const io = yield* TerminalIo;
149
+ yield* Match.value(event).pipe(
150
+ Match.when({ type: "activity" }, () => Effect.void),
151
+ Match.when({ type: "snapshot" }, ({ snapshot }) => writeSnapshot(snapshot.history)),
152
+ Match.when({ type: "restarted" }, ({ snapshot }) => writeSnapshot(snapshot.history)),
153
+ Match.when({ type: "output" }, ({ data }) => io.writeOutput(data)),
154
+ Match.when({ type: "cleared" }, () => io.writeOutput(ANSI_CLEAR_SCREEN)),
155
+ Match.when({ type: "error" }, ({ message }) => writeSystemMessage(message)),
156
+ Match.when({ type: "closed" }, () => writeSystemMessage("Terminal closed")),
157
+ Match.orElse((next) => {
158
+ const details = [
159
+ typeof next.exitCode === "number" ? `code ${next.exitCode}` : null,
160
+ typeof next.exitSignal === "number" ? `signal ${next.exitSignal}` : null,
161
+ ]
162
+ .filter((value): value is string => value !== null)
163
+ .join(", ");
164
+ return writeSystemMessage(
165
+ details.length > 0 ? `Process exited (${details})` : "Process exited",
166
+ );
167
+ }),
168
+ );
169
+ });
170
+ }
171
+
172
+ function writeSnapshot(history: string) {
173
+ return Effect.gen(function* () {
174
+ const io = yield* TerminalIo;
175
+ yield* io.writeOutput(ANSI_CLEAR_SCREEN);
176
+ if (history.length > 0) {
177
+ yield* io.writeOutput(history);
178
+ }
179
+ });
180
+ }
181
+
182
+ function writeSystemMessage(message: string) {
183
+ return Effect.gen(function* () {
184
+ const io = yield* TerminalIo;
185
+ yield* io.writeOutput(`\r\n[terminal] ${message}\r\n`);
186
+ });
187
+ }
188
+
189
+ export function toTerminalAttachTarget(terminal: TerminalSummary): TerminalAttachTarget {
190
+ return {
191
+ threadId: terminal.threadId,
192
+ terminalId: terminal.terminalId,
193
+ cwd: terminal.cwd,
194
+ worktreePath: terminal.worktreePath,
195
+ };
196
+ }
197
+
198
+ export function snapshotToTerminalAttachTarget(
199
+ snapshot: TerminalSessionSnapshot,
200
+ ): TerminalAttachTarget {
201
+ return {
202
+ threadId: snapshot.threadId,
203
+ terminalId: snapshot.terminalId,
204
+ cwd: snapshot.cwd,
205
+ worktreePath: snapshot.worktreePath,
206
+ };
207
+ }
208
+
209
+ export function toTerminalRef(terminal: TerminalSummary): TerminalRef {
210
+ return {
211
+ threadId: terminal.threadId,
212
+ terminalId: terminal.terminalId,
213
+ };
214
+ }
215
+
216
+ export function filterAttachStreamEvent(
217
+ event: TerminalAttachStreamEvent,
218
+ options: {
219
+ readonly includeHistory: boolean;
220
+ readonly fromSequence?: number;
221
+ },
222
+ ): TerminalAttachStreamEvent | null {
223
+ if (event.type === "snapshot" || event.type === "restarted") {
224
+ if (
225
+ options.fromSequence !== undefined &&
226
+ event.type === "restarted" &&
227
+ typeof event.snapshot.sequence === "number" &&
228
+ event.snapshot.sequence < options.fromSequence
229
+ ) {
230
+ return null;
231
+ }
232
+ return {
233
+ ...event,
234
+ snapshot: {
235
+ ...event.snapshot,
236
+ history: options.includeHistory ? event.snapshot.history : "",
237
+ },
238
+ };
239
+ }
240
+
241
+ if (
242
+ options.fromSequence !== undefined &&
243
+ typeof event.sequence === "number" &&
244
+ event.sequence < options.fromSequence
245
+ ) {
246
+ return null;
247
+ }
248
+
249
+ return event;
250
+ }
@@ -0,0 +1,64 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Stream from "effect/Stream";
4
+ import { Argument, Command, Flag } from "effect/unstable/cli";
5
+
6
+ import { T3Application } from "../../application/service.ts";
7
+ import { Environment } from "../../environment/service.ts";
8
+ import { InvalidLimitError } from "../error.ts";
9
+ import { threadFlag } from "../flags.ts";
10
+ import { T3Output } from "../output/service.ts";
11
+ import { requireCommandThreadId } from "./scope.ts";
12
+ import { filterAttachStreamEvent, toTerminalAttachTarget } from "./shared.ts";
13
+
14
+ const ndjsonOnlyFormatChoices = ["ndjson"] as const;
15
+
16
+ export const streamTerminalCommand = Command.make(
17
+ "stream",
18
+ {
19
+ thread: threadFlag,
20
+ terminalId: Argument.string("terminal-id"),
21
+ fromSequence: Flag.integer("from-sequence").pipe(Flag.optional),
22
+ format: Flag.choice("format", ndjsonOnlyFormatChoices).pipe(Flag.withDefault("ndjson")),
23
+ },
24
+ ({ thread, terminalId, fromSequence }) =>
25
+ Effect.gen(function* () {
26
+ const output = yield* T3Output;
27
+ const application = yield* T3Application;
28
+ const environment = yield* Environment;
29
+ const threadId = yield* requireCommandThreadId({
30
+ thread,
31
+ env: environment.env,
32
+ });
33
+ const fromSequenceValue = Option.getOrUndefined(fromSequence);
34
+
35
+ if (fromSequenceValue !== undefined && fromSequenceValue < 0) {
36
+ yield* Effect.fail(
37
+ new InvalidLimitError({
38
+ message: "from-sequence must be a non-negative integer",
39
+ value: String(fromSequenceValue),
40
+ }),
41
+ );
42
+ }
43
+
44
+ const terminal = yield* application.getTerminal({
45
+ threadId,
46
+ terminalId,
47
+ });
48
+ const stream = application
49
+ .attachTerminal({
50
+ terminal: toTerminalAttachTarget(terminal),
51
+ })
52
+ .pipe(
53
+ Stream.map((event) =>
54
+ filterAttachStreamEvent(event, {
55
+ includeHistory: true,
56
+ ...(fromSequenceValue !== undefined ? { fromSequence: fromSequenceValue } : {}),
57
+ }),
58
+ ),
59
+ Stream.filter((event): event is NonNullable<typeof event> => event !== null),
60
+ );
61
+
62
+ yield* Stream.runForEach(stream, (event) => output.printNdjson(event));
63
+ }),
64
+ ).pipe(Command.withDescription("stream terminal attach events as ndjson"));
@@ -0,0 +1,146 @@
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 * as Stream from "effect/Stream";
10
+ import { assert, describe, expect, it } from "@effect/vitest";
11
+ import { Command } from "effect/unstable/cli";
12
+ import { fromPartial } from "@total-typescript/shoehorn";
13
+
14
+ import { T3Application } from "../../application/service.ts";
15
+ import { T3Config } from "../../config/service.ts";
16
+ import { NodeEnvironmentLive } from "../../environment/layer.ts";
17
+ import { T3Output } from "../output/service.ts";
18
+ import { TerminalCliError } from "./error.ts";
19
+ import { resolveMetadataWait, waitTerminalCommand } from "./wait.ts";
20
+
21
+ describe("resolveMetadataWait", () => {
22
+ it("resolves closed immediately when terminal is missing from snapshot", () => {
23
+ const resolution = resolveMetadataWait(
24
+ { type: "snapshot", terminals: [] },
25
+ "closed",
26
+ "thread-1",
27
+ "term-1",
28
+ );
29
+ expect(resolution).toEqual({
30
+ kind: "result",
31
+ value: {
32
+ threadId: "thread-1",
33
+ terminalId: "term-1",
34
+ target: "closed",
35
+ status: "closed",
36
+ alreadySatisfied: true,
37
+ missingTreatedAsClosed: true,
38
+ },
39
+ });
40
+ });
41
+
42
+ it("uses the same exited error when terminal is missing from snapshot or removed", () => {
43
+ const missing = resolveMetadataWait(
44
+ { type: "snapshot", terminals: [] },
45
+ "exited",
46
+ "thread-1",
47
+ "term-1",
48
+ );
49
+ const removed = resolveMetadataWait(
50
+ { type: "remove", threadId: "thread-1", terminalId: "term-1" },
51
+ "exited",
52
+ "thread-1",
53
+ "term-1",
54
+ );
55
+
56
+ expect(missing?.kind).toBe("fail");
57
+ expect(removed?.kind).toBe("fail");
58
+ if (missing?.kind === "fail" && removed?.kind === "fail") {
59
+ expect(missing.error.message).toBe(removed.error.message);
60
+ expect(missing.error).toBeInstanceOf(TerminalCliError);
61
+ }
62
+ });
63
+ });
64
+
65
+ describe("waitTerminalCommand", () => {
66
+ const testLayer = Layer.mergeAll(
67
+ Layer.succeed(
68
+ T3Application,
69
+ fromPartial({
70
+ getTerminal: () => Effect.die("getTerminal should not be called"),
71
+ watchTerminalMetadata: () =>
72
+ Stream.make({
73
+ type: "snapshot" as const,
74
+ terminals: [],
75
+ }),
76
+ }),
77
+ ),
78
+ Layer.succeed(
79
+ T3Config,
80
+ fromPartial({
81
+ resolve: () =>
82
+ Effect.succeed({
83
+ url: "ws://localhost",
84
+ token: "token",
85
+ source: "config",
86
+ local: true,
87
+ }),
88
+ }),
89
+ ),
90
+ Layer.succeed(T3Output, {
91
+ writeStdout: () => Effect.void,
92
+ writeStderr: () => Effect.void,
93
+ printJson: () => Effect.void,
94
+ printNdjson: () => Effect.void,
95
+ printInfo: () => Effect.void,
96
+ }),
97
+ NodeServices.layer,
98
+ NodeEnvironmentLive,
99
+ );
100
+
101
+ it.layer(testLayer)("waitTerminalCommand", (t) => {
102
+ t.effect("does not require getTerminal before waiting for closed", () =>
103
+ Effect.gen(function* () {
104
+ const run = Command.runWith(waitTerminalCommand, { version: "0.0.0-test" });
105
+ const exit = yield* run([
106
+ "--thread",
107
+ "thread-1",
108
+ "term-1",
109
+ "--for",
110
+ "closed",
111
+ "--format",
112
+ "json",
113
+ ]).pipe(Effect.exit);
114
+ assert.isTrue(Exit.isSuccess(exit));
115
+ }),
116
+ );
117
+
118
+ t.effect("uses exited error without getTerminal when terminal is already missing", () =>
119
+ Effect.gen(function* () {
120
+ const run = Command.runWith(waitTerminalCommand, { version: "0.0.0-test" });
121
+ const exit = yield* run([
122
+ "--thread",
123
+ "thread-1",
124
+ "term-1",
125
+ "--for",
126
+ "exited",
127
+ "--format",
128
+ "json",
129
+ ]).pipe(Effect.exit);
130
+ assert.isTrue(Exit.isFailure(exit));
131
+ if (!Exit.isFailure(exit)) {
132
+ return;
133
+ }
134
+ const error = Cause.findErrorOption(exit.cause);
135
+ assert.isTrue(Option.isSome(error));
136
+ if (Option.isSome(error)) {
137
+ assert.instanceOf(error.value, TerminalCliError);
138
+ assert.equal(
139
+ error.value.message,
140
+ "terminal closed before an exited event was observed: term-1",
141
+ );
142
+ }
143
+ }),
144
+ );
145
+ });
146
+ });