t3code-cli 0.1.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/bin.js +17842 -0
  4. package/dist/index.js +2 -0
  5. package/dist/runtime-DU_hs0MM.js +61631 -0
  6. package/package.json +59 -0
  7. package/src/application/error.ts +4 -0
  8. package/src/application/layer.ts +20 -0
  9. package/src/application/model-selection.ts +109 -0
  10. package/src/application/models.ts +26 -0
  11. package/src/application/project-commands.ts +28 -0
  12. package/src/application/projects.ts +50 -0
  13. package/src/application/service.ts +93 -0
  14. package/src/application/shell-sequence.ts +29 -0
  15. package/src/application/thread-commands.ts +89 -0
  16. package/src/application/thread-wait.ts +86 -0
  17. package/src/application/threads.ts +172 -0
  18. package/src/auth/error.ts +42 -0
  19. package/src/auth/layer.ts +114 -0
  20. package/src/auth/local.ts +241 -0
  21. package/src/auth/pairing.ts +54 -0
  22. package/src/auth/schema.ts +55 -0
  23. package/src/auth/service.ts +16 -0
  24. package/src/auth/transport.ts +132 -0
  25. package/src/auth/type.ts +31 -0
  26. package/src/bin.ts +47 -0
  27. package/src/cli/app.ts +18 -0
  28. package/src/cli/auth-format.ts +43 -0
  29. package/src/cli/auth.ts +99 -0
  30. package/src/cli/error.ts +16 -0
  31. package/src/cli/input/error.ts +11 -0
  32. package/src/cli/input/layer.ts +31 -0
  33. package/src/cli/input/service.ts +11 -0
  34. package/src/cli/message-input.ts +25 -0
  35. package/src/cli/model-format.ts +18 -0
  36. package/src/cli/model-options.ts +56 -0
  37. package/src/cli/models.ts +45 -0
  38. package/src/cli/output/error.ts +11 -0
  39. package/src/cli/output/layer.ts +53 -0
  40. package/src/cli/output/service.ts +15 -0
  41. package/src/cli/output-format.ts +41 -0
  42. package/src/cli/project-format.ts +11 -0
  43. package/src/cli/projects.ts +62 -0
  44. package/src/cli/thread-format.ts +74 -0
  45. package/src/cli/threads/archive.ts +28 -0
  46. package/src/cli/threads/list.ts +29 -0
  47. package/src/cli/threads/messages.ts +36 -0
  48. package/src/cli/threads/send.ts +91 -0
  49. package/src/cli/threads/start.ts +136 -0
  50. package/src/cli/threads/wait.ts +35 -0
  51. package/src/cli/threads.ts +22 -0
  52. package/src/cli/wait-events.ts +112 -0
  53. package/src/config/error.ts +21 -0
  54. package/src/config/layer.ts +103 -0
  55. package/src/config/service.ts +24 -0
  56. package/src/config/url.ts +55 -0
  57. package/src/domain/command-schema.ts +82 -0
  58. package/src/domain/error.ts +46 -0
  59. package/src/domain/helpers.ts +23 -0
  60. package/src/domain/model-config.ts +40 -0
  61. package/src/domain/schema.ts +162 -0
  62. package/src/domain/thread-lifecycle.ts +97 -0
  63. package/src/environment/layer.ts +12 -0
  64. package/src/environment/service.ts +13 -0
  65. package/src/index.ts +18 -0
  66. package/src/orchestration/layer.ts +193 -0
  67. package/src/orchestration/service.ts +39 -0
  68. package/src/protocol/schema.ts +105 -0
  69. package/src/rpc/error.ts +31 -0
  70. package/src/rpc/layer.ts +99 -0
  71. package/src/rpc/service.ts +16 -0
  72. package/src/runtime.ts +28 -0
  73. package/src/version/layer.ts +25 -0
  74. package/src/version/service.ts +8 -0
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "t3code-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for t3code",
5
+ "keywords": [
6
+ "claude",
7
+ "cli",
8
+ "codex",
9
+ "t3code"
10
+ ],
11
+ "homepage": "https://github.com/tarik02/t3cli#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/tarik02/t3cli/issues"
14
+ },
15
+ "license": "MIT",
16
+ "author": "tarik02 <taras.fomin@gmail.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/tarik02/t3cli.git"
20
+ },
21
+ "bin": {
22
+ "t3cli": "./dist/bin.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src"
27
+ ],
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "import": "./dist/index.js",
32
+ "types": "./src/index.ts"
33
+ }
34
+ },
35
+ "devDependencies": {
36
+ "@changesets/cli": "^2.31.0",
37
+ "@effect/platform-node": "4.0.0-beta.74",
38
+ "@types/node": "^25.9.1",
39
+ "effect": "4.0.0-beta.74",
40
+ "typescript": "^6.0.3",
41
+ "vite-plus": "^0.1.24"
42
+ },
43
+ "engines": {
44
+ "node": "^24.0.0"
45
+ },
46
+ "scripts": {
47
+ "build": "vp pack",
48
+ "check": "vp check",
49
+ "format": "vp fmt",
50
+ "format:check": "vp fmt --check",
51
+ "lint": "vp lint",
52
+ "lint:fix": "vp lint --fix",
53
+ "changeset": "changeset",
54
+ "release:check": "pnpm check && pnpm typecheck && pnpm pack --dry-run",
55
+ "release:publish": "pnpm publish --access public --no-git-checks",
56
+ "release:publish:dry-run": "pnpm publish --access public --dry-run --no-git-checks",
57
+ "typecheck": "tsc --noEmit"
58
+ }
59
+ }
@@ -0,0 +1,4 @@
1
+ import type { DomainError } from "../domain/error.ts";
2
+ import type { RpcError } from "../rpc/error.ts";
3
+
4
+ export type ApplicationError = DomainError | RpcError;
@@ -0,0 +1,20 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Layer from "effect/Layer";
3
+
4
+ import { makeModelsApplication } from "./models.ts";
5
+ import { makeProjectApplication } from "./projects.ts";
6
+ import { T3Application } from "./service.ts";
7
+ import { makeThreadApplication } from "./threads.ts";
8
+
9
+ export const makeT3Application = Effect.fn("makeT3Application")(function* () {
10
+ const models = yield* makeModelsApplication();
11
+ const projects = yield* makeProjectApplication();
12
+ const threads = yield* makeThreadApplication();
13
+ return {
14
+ ...models,
15
+ ...projects,
16
+ ...threads,
17
+ };
18
+ });
19
+
20
+ export const T3ApplicationLive = Layer.effect(T3Application, makeT3Application());
@@ -0,0 +1,109 @@
1
+ import * as Effect from "effect/Effect";
2
+
3
+ import { ModelSelectionError } from "../domain/error.ts";
4
+ import {
5
+ findSelectableProvider,
6
+ firstSelectableModel,
7
+ firstSelectableProvider,
8
+ } from "../domain/model-config.ts";
9
+ import {
10
+ decodeModelSelection,
11
+ type ModelSelection,
12
+ type ProjectShell,
13
+ type ServerConfig,
14
+ } from "../domain/schema.ts";
15
+ import type { StartThreadInput } from "./service.ts";
16
+
17
+ export function resolveModelSelection(input: {
18
+ readonly start: StartThreadInput;
19
+ readonly project: ProjectShell;
20
+ readonly serverConfig: ServerConfig;
21
+ }) {
22
+ return Effect.gen(function* () {
23
+ if (input.start.provider !== undefined && input.start.model !== undefined) {
24
+ return withModelOptions(input.start, {
25
+ instanceId: input.start.provider,
26
+ model: input.start.model,
27
+ });
28
+ }
29
+ if (input.start.provider !== undefined) {
30
+ const provider = yield* findProvider(input.serverConfig, input.start.provider);
31
+ const model = firstSelectableModel(provider);
32
+ if (model === undefined) {
33
+ return yield* failNoAvailableModel();
34
+ }
35
+ return withModelOptions(input.start, {
36
+ instanceId: input.start.provider,
37
+ model: model.slug,
38
+ });
39
+ }
40
+ if (input.start.model !== undefined) {
41
+ const provider = yield* firstAvailableModel(input.serverConfig);
42
+ return withModelOptions(input.start, {
43
+ instanceId: provider.instanceId,
44
+ model: input.start.model,
45
+ });
46
+ }
47
+ if (input.project.defaultModelSelection !== null) {
48
+ return withModelOptions(input.start, input.project.defaultModelSelection);
49
+ }
50
+ const provider = yield* firstAvailableModel(input.serverConfig);
51
+ const model = firstSelectableModel(provider);
52
+ if (model === undefined) {
53
+ return yield* failNoAvailableModel();
54
+ }
55
+ return withModelOptions(input.start, {
56
+ instanceId: provider.instanceId,
57
+ model: model.slug,
58
+ });
59
+ });
60
+ }
61
+
62
+ export function mergeModelOptions(
63
+ selection: ModelSelection,
64
+ options: NonNullable<ModelSelection["options"]>,
65
+ ): ModelSelection {
66
+ if (options.length === 0) {
67
+ return selection;
68
+ }
69
+ const optionsById = new Map((selection.options ?? []).map((option) => [option.id, option]));
70
+ for (const option of options) {
71
+ optionsById.set(option.id, option);
72
+ }
73
+ return decodeModelSelection({
74
+ ...selection,
75
+ options: [...optionsById.values()],
76
+ });
77
+ }
78
+
79
+ function withModelOptions(input: StartThreadInput, selection: ModelSelection): ModelSelection {
80
+ if (input.options === undefined || input.options.length === 0) {
81
+ return selection;
82
+ }
83
+ return mergeModelOptions(selection, input.options);
84
+ }
85
+
86
+ function firstAvailableModel(serverConfig: ServerConfig) {
87
+ const providers = serverConfig.providers ?? [];
88
+ const provider = firstSelectableProvider(providers);
89
+ if (provider === undefined) {
90
+ return failNoAvailableModel();
91
+ }
92
+ return Effect.succeed(provider);
93
+ }
94
+
95
+ function findProvider(serverConfig: ServerConfig, instanceId: string) {
96
+ const provider = findSelectableProvider(serverConfig.providers ?? [], instanceId);
97
+ if (provider === undefined) {
98
+ return failNoAvailableModel();
99
+ }
100
+ return Effect.succeed(provider);
101
+ }
102
+
103
+ function failNoAvailableModel() {
104
+ return Effect.fail(
105
+ new ModelSelectionError({
106
+ message: "no available provider model found; pass --provider and --model",
107
+ }),
108
+ );
109
+ }
@@ -0,0 +1,26 @@
1
+ import * as Effect from "effect/Effect";
2
+
3
+ import { filterProvidersForModelListing } from "../domain/model-config.ts";
4
+ import { T3Orchestration } from "../orchestration/service.ts";
5
+
6
+ export const makeModelsApplication = Effect.fn("makeModelsApplication")(function* () {
7
+ const orchestration = yield* T3Orchestration;
8
+
9
+ const listModels = Effect.fn("T3Application.listModels")(function* (input: {
10
+ readonly all?: boolean;
11
+ readonly provider?: string;
12
+ }) {
13
+ const config = yield* orchestration.getServerConfig();
14
+ return filterProvidersForModelListing({
15
+ providers: config.providers ?? [],
16
+ all: input.all === true,
17
+ ...(input.provider !== undefined && input.provider.length > 0
18
+ ? { provider: input.provider }
19
+ : {}),
20
+ });
21
+ });
22
+
23
+ return {
24
+ listModels,
25
+ };
26
+ });
@@ -0,0 +1,28 @@
1
+ import * as Crypto from "effect/Crypto";
2
+ import * as DateTime from "effect/DateTime";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Path from "effect/Path";
5
+
6
+ import { Environment } from "../environment/service.ts";
7
+ import type { ProjectCreateCommand } from "../domain/command-schema.ts";
8
+
9
+ export const makeProjectCreateCommand = Effect.fn("makeProjectCreateCommand")(function* (input: {
10
+ readonly path: string;
11
+ readonly title?: string;
12
+ }) {
13
+ const path = yield* Path.Path;
14
+ const crypto = yield* Crypto.Crypto;
15
+ const environment = yield* Environment;
16
+ const workspaceRoot = path.resolve(environment.cwd, input.path);
17
+ const projectId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
18
+ const title = input.title?.trim();
19
+ const createdAt = DateTime.formatIso(yield* DateTime.now);
20
+ return {
21
+ type: "project.create",
22
+ commandId: `t3cli:project-create:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
23
+ projectId,
24
+ title: title !== undefined && title.length > 0 ? title : path.basename(workspaceRoot),
25
+ workspaceRoot,
26
+ createdAt,
27
+ } satisfies ProjectCreateCommand & { readonly projectId: string };
28
+ });
@@ -0,0 +1,50 @@
1
+ import * as Crypto from "effect/Crypto";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Path from "effect/Path";
4
+
5
+ import { Environment } from "../environment/service.ts";
6
+ import { T3Orchestration } from "../orchestration/service.ts";
7
+ import { ProjectCreateVisibilityError } from "../domain/error.ts";
8
+ import { findProjectById } from "../domain/helpers.ts";
9
+ import { makeProjectCreateCommand } from "./project-commands.ts";
10
+ import { waitForShellSequence } from "./shell-sequence.ts";
11
+
12
+ export const makeProjectApplication = Effect.fn("makeProjectApplication")(function* () {
13
+ const orchestration = yield* T3Orchestration;
14
+ const crypto = yield* Crypto.Crypto;
15
+ const path = yield* Path.Path;
16
+ const environment = yield* Environment;
17
+ const loadShell = Effect.fn("T3ApplicationLive.loadShell")(function* () {
18
+ return yield* orchestration.getShellSnapshot();
19
+ });
20
+ const addProject = Effect.fn("T3ApplicationLive.addProject")(function* (projectInput: {
21
+ readonly path: string;
22
+ readonly title?: string;
23
+ }) {
24
+ const command = yield* makeProjectCreateCommand(projectInput).pipe(
25
+ Effect.provideService(Path.Path, path),
26
+ Effect.provideService(Crypto.Crypto, crypto),
27
+ Effect.provideService(Environment, environment),
28
+ );
29
+ const dispatch = yield* orchestration.dispatch(command);
30
+ const snapshot = yield* waitForShellSequence({
31
+ orchestration,
32
+ sequence: dispatch.sequence,
33
+ });
34
+ const project = findProjectById(snapshot, command.projectId);
35
+ if (project === null) {
36
+ return yield* Effect.fail(
37
+ new ProjectCreateVisibilityError({
38
+ message: `project created but not visible in shell snapshot: ${command.projectId}`,
39
+ projectId: command.projectId,
40
+ }),
41
+ );
42
+ }
43
+ return { dispatch, project };
44
+ });
45
+
46
+ return {
47
+ loadShell,
48
+ addProject,
49
+ };
50
+ });
@@ -0,0 +1,93 @@
1
+ import * as Context from "effect/Context";
2
+ import type * as Effect from "effect/Effect";
3
+ import type * as Stream from "effect/Stream";
4
+
5
+ import type { ApplicationError } from "./error.ts";
6
+ import type { DispatchResult } from "../domain/command-schema.ts";
7
+ import type {
8
+ ProjectShell,
9
+ ServerProvider,
10
+ ShellSnapshot,
11
+ ThreadDetail,
12
+ ThreadMessage,
13
+ ThreadShell,
14
+ ModelSelection,
15
+ } from "../domain/schema.ts";
16
+
17
+ export type StartThreadInput = {
18
+ readonly projectRef: string;
19
+ readonly message: string;
20
+ readonly title?: string;
21
+ readonly provider?: string;
22
+ readonly model?: string;
23
+ readonly options?: NonNullable<ModelSelection["options"]>;
24
+ readonly worktreePath?: string;
25
+ };
26
+
27
+ export type SendThreadInput = {
28
+ readonly threadId: string;
29
+ readonly message: string;
30
+ readonly options?: NonNullable<ModelSelection["options"]>;
31
+ };
32
+
33
+ export type StartThreadPolicy = {
34
+ readonly until: "dispatch" | "visible" | "complete";
35
+ };
36
+
37
+ export type WaitEvent =
38
+ | { readonly type: "thread"; readonly thread: ThreadDetail }
39
+ | { readonly type: "message"; readonly message: ThreadMessage }
40
+ | { readonly type: "status"; readonly status: string; readonly threadId: string }
41
+ | { readonly type: "done"; readonly thread: ThreadDetail };
42
+
43
+ export class T3Application extends Context.Service<
44
+ T3Application,
45
+ {
46
+ readonly loadShell: () => Effect.Effect<ShellSnapshot, ApplicationError>;
47
+ readonly listModels: (input: {
48
+ readonly all?: boolean;
49
+ readonly provider?: string;
50
+ }) => Effect.Effect<ReadonlyArray<ServerProvider>, ApplicationError>;
51
+ readonly addProject: (input: {
52
+ readonly path: string;
53
+ readonly title?: string;
54
+ }) => Effect.Effect<
55
+ { readonly dispatch: DispatchResult; readonly project: ProjectShell },
56
+ ApplicationError
57
+ >;
58
+ readonly listThreads: (projectRef: string) => Effect.Effect<
59
+ {
60
+ readonly project: ProjectShell;
61
+ readonly threads: ReadonlyArray<ThreadShell>;
62
+ },
63
+ ApplicationError
64
+ >;
65
+ readonly getThreadMessages: (threadId: string) => Effect.Effect<ThreadDetail, ApplicationError>;
66
+ readonly archiveThread: (threadId: string) => Effect.Effect<DispatchResult, ApplicationError>;
67
+ readonly startThread: (
68
+ input: StartThreadInput,
69
+ policy?: StartThreadPolicy,
70
+ ) => Effect.Effect<
71
+ {
72
+ readonly dispatch: DispatchResult;
73
+ readonly project: ProjectShell;
74
+ readonly threadId: string;
75
+ readonly thread?: ThreadDetail;
76
+ },
77
+ ApplicationError
78
+ >;
79
+ readonly sendThread: (
80
+ input: SendThreadInput,
81
+ policy?: StartThreadPolicy,
82
+ ) => Effect.Effect<
83
+ {
84
+ readonly dispatch: DispatchResult;
85
+ readonly threadId: string;
86
+ readonly thread?: ThreadDetail;
87
+ },
88
+ ApplicationError
89
+ >;
90
+ readonly watchThread: (threadId: string) => Stream.Stream<WaitEvent, ApplicationError>;
91
+ readonly waitForThread: (threadId: string) => Effect.Effect<ThreadDetail, ApplicationError>;
92
+ }
93
+ >()("t3cli/T3Application") {}
@@ -0,0 +1,29 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Stream from "effect/Stream";
4
+
5
+ import type { Orchestration } from "../orchestration/service.ts";
6
+ import { RpcError } from "../rpc/error.ts";
7
+ import { ORCHESTRATION_WS_METHODS } from "../protocol/schema.ts";
8
+
9
+ export function waitForShellSequence(input: {
10
+ readonly orchestration: Orchestration;
11
+ readonly sequence: number;
12
+ }) {
13
+ return Effect.gen(function* () {
14
+ const sequence = yield* input.orchestration.watchShellSequence().pipe(
15
+ Stream.filter((snapshotSequence) => snapshotSequence >= input.sequence),
16
+ Stream.runHead,
17
+ Effect.scoped,
18
+ );
19
+ if (Option.isNone(sequence)) {
20
+ return yield* Effect.fail(
21
+ new RpcError({
22
+ message: `shell stream ended before sequence ${input.sequence}`,
23
+ method: ORCHESTRATION_WS_METHODS.subscribeShell,
24
+ }),
25
+ );
26
+ }
27
+ return yield* input.orchestration.getShellSnapshot();
28
+ });
29
+ }
@@ -0,0 +1,89 @@
1
+ import * as Crypto from "effect/Crypto";
2
+ import * as DateTime from "effect/DateTime";
3
+ import * as Effect from "effect/Effect";
4
+
5
+ import type {
6
+ ThreadArchiveCommand,
7
+ ThreadCreateCommand,
8
+ ThreadTurnStartCommand,
9
+ } from "../domain/command-schema.ts";
10
+ import type { ModelSelection, ProjectShell, ServerConfig } from "../domain/schema.ts";
11
+ import { resolveModelSelection } from "./model-selection.ts";
12
+ import type { SendThreadInput, StartThreadInput } from "./service.ts";
13
+
14
+ export const makeThreadStartCommands = Effect.fn("makeThreadStartCommands")(function* (input: {
15
+ readonly start: StartThreadInput;
16
+ readonly project: ProjectShell;
17
+ readonly serverConfig: ServerConfig;
18
+ }) {
19
+ const crypto = yield* Crypto.Crypto;
20
+ const threadId = yield* crypto.randomUUIDv4.pipe(Effect.orDie);
21
+ const createdAt = DateTime.formatIso(yield* DateTime.now);
22
+ const modelSelection = yield* resolveModelSelection(input);
23
+ const inputTitle = input.start.title?.trim();
24
+ const messageTitle = input.start.message.trim().split(/\s+/).slice(0, 8).join(" ");
25
+ const title = inputTitle !== undefined && inputTitle.length > 0 ? inputTitle : messageTitle;
26
+ const createCommand = {
27
+ type: "thread.create",
28
+ commandId: `t3cli:thread-create:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
29
+ threadId,
30
+ projectId: input.project.id,
31
+ title: title.length > 0 ? title : "New thread",
32
+ modelSelection,
33
+ runtimeMode: "full-access",
34
+ interactionMode: "default",
35
+ branch: null,
36
+ worktreePath: input.start.worktreePath ?? null,
37
+ createdAt,
38
+ } satisfies ThreadCreateCommand;
39
+ const turnCommand = {
40
+ type: "thread.turn.start",
41
+ commandId: `t3cli:thread-start:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
42
+ threadId,
43
+ message: {
44
+ messageId: yield* crypto.randomUUIDv4.pipe(Effect.orDie),
45
+ role: "user",
46
+ text: input.start.message,
47
+ attachments: [],
48
+ },
49
+ modelSelection,
50
+ titleSeed: title,
51
+ runtimeMode: "full-access",
52
+ interactionMode: "default",
53
+ createdAt,
54
+ } satisfies ThreadTurnStartCommand;
55
+ return { createCommand, turnCommand, threadId };
56
+ });
57
+
58
+ export const makeThreadTurnContinueCommand = Effect.fn("makeThreadTurnContinueCommand")(function* (
59
+ input: SendThreadInput & { readonly modelSelection?: ModelSelection },
60
+ ) {
61
+ const crypto = yield* Crypto.Crypto;
62
+ const createdAt = DateTime.formatIso(yield* DateTime.now);
63
+ return {
64
+ type: "thread.turn.start",
65
+ commandId: `t3cli:thread-start:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
66
+ threadId: input.threadId,
67
+ message: {
68
+ messageId: yield* crypto.randomUUIDv4.pipe(Effect.orDie),
69
+ role: "user",
70
+ text: input.message,
71
+ attachments: [],
72
+ },
73
+ runtimeMode: "full-access",
74
+ interactionMode: "default",
75
+ ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
76
+ createdAt,
77
+ } satisfies ThreadTurnStartCommand;
78
+ });
79
+
80
+ export const makeThreadArchiveCommand = Effect.fn("makeThreadArchiveCommand")(function* (
81
+ threadId: string,
82
+ ) {
83
+ const crypto = yield* Crypto.Crypto;
84
+ return {
85
+ type: "thread.archive",
86
+ commandId: `t3cli:thread-archive:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
87
+ threadId,
88
+ } satisfies ThreadArchiveCommand;
89
+ });
@@ -0,0 +1,86 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Stream from "effect/Stream";
4
+
5
+ import { ThreadSessionError } from "../domain/error.ts";
6
+ import type { Orchestration } from "../orchestration/service.ts";
7
+ import type { ThreadDetail, ThreadMessage } from "../domain/schema.ts";
8
+ import type { WaitEvent } from "./service.ts";
9
+ import {
10
+ applyThreadEvent,
11
+ isThreadActive,
12
+ isThreadCompleteEnough,
13
+ messageFromEvent,
14
+ messageKey,
15
+ threadStatus,
16
+ } from "../domain/thread-lifecycle.ts";
17
+
18
+ export function watchThread(input: {
19
+ readonly orchestration: Orchestration;
20
+ readonly threadId: string;
21
+ }) {
22
+ let current: ThreadDetail | undefined;
23
+ let currentMessages: Map<string, ThreadMessage> | undefined;
24
+ return Stream.scoped(
25
+ input.orchestration.watchThreadItems(input.threadId).pipe(
26
+ Stream.flatMap((item) => {
27
+ if (item.kind === "snapshot") {
28
+ const messages = new Map<string, ThreadMessage>();
29
+ for (const message of item.snapshot.thread.messages) {
30
+ messages.set(messageKey(message), message);
31
+ }
32
+ current = item.snapshot.thread;
33
+ currentMessages = messages;
34
+
35
+ const events: Array<WaitEvent> = [
36
+ { type: "thread", thread: current },
37
+ { type: "status", status: threadStatus(current), threadId: current.id },
38
+ ];
39
+ if (!isThreadActive(current) && isThreadCompleteEnough(current)) {
40
+ events.push({ type: "done", thread: current });
41
+ }
42
+ return Stream.fromIterable(events);
43
+ }
44
+
45
+ if (current === undefined || currentMessages === undefined) {
46
+ return Stream.fail(
47
+ new ThreadSessionError({
48
+ message: `thread stream event received before snapshot: ${input.threadId}`,
49
+ threadId: input.threadId,
50
+ }),
51
+ );
52
+ }
53
+
54
+ current = applyThreadEvent(current, item.event, currentMessages);
55
+ const message = messageFromEvent(item.event, currentMessages);
56
+ const events: Array<WaitEvent> = message !== null ? [{ type: "message", message }] : [];
57
+ events.push({ type: "status", status: threadStatus(current), threadId: current.id });
58
+ if (!isThreadActive(current) && isThreadCompleteEnough(current)) {
59
+ events.push({ type: "done", thread: current });
60
+ }
61
+ return Stream.fromIterable(events);
62
+ }),
63
+ Stream.takeUntil((event) => event.type === "done"),
64
+ ),
65
+ );
66
+ }
67
+
68
+ export function waitForThread(input: {
69
+ readonly orchestration: Orchestration;
70
+ readonly threadId: string;
71
+ }) {
72
+ return watchThread(input).pipe(
73
+ Stream.runLast,
74
+ Effect.flatMap((event) => {
75
+ if (Option.isSome(event) && event.value.type === "done") {
76
+ return Effect.succeed(event.value.thread);
77
+ }
78
+ return Effect.fail(
79
+ new ThreadSessionError({
80
+ message: `thread wait ended without done event: ${input.threadId}`,
81
+ threadId: input.threadId,
82
+ }),
83
+ );
84
+ }),
85
+ );
86
+ }