Skip to content

Commit 0114d6d

Browse files
committed
Merge branch 'feat-desktop-wave-2' into canary
2 parents 414f2b9 + 32374bd commit 0114d6d

62 files changed

Lines changed: 2968 additions & 864 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ This file is the canonical source for unreleased changes and published release n
1111
### Added
1212

1313
- Added Agent Skills (`codework.prompt.skill`). A skill is a directory with a `SKILL.md` whose frontmatter has a `name` and a `description`; `.codework/skills` may also hold standalone `.md` skills. Skills are found in the session's sandbox, local or remote, and never on the host or in `<home>`: `.codework/skills` in the working directory, then `.agents/skills` in the working directory and each parent up to the git root (or up to the sandbox root outside a repository). `.gitignore`, `.ignore` and `.fdignore` are honoured. The system prompt lists only each skill's name, description and location under `<skills>`; the model reads the full file when a task matches. A skill with `disable-model-invocation: true` is not listed. Invalid fields, unreadable files and name collisions log a warning instead of failing; the first skill found keeps the name. The hint names the `read` tool, or `bash` when `read` is off, and names no tool when the reader is hidden from the prompt. Turn it off with `{ "plugin": "codework.prompt.skill", "enabled": false }` in `plugins`.
14+
- Added `project: { name }` to `session.create`. It names the session's project when the session creates it, ahead of the repository's and the folder's name; a project that already exists keeps its name. The name must be trimmed and not empty.
15+
- Added the `project.rename` RPC (`{ projectId, name }`), which renames a project and fails with `ProjectNotFoundError` for an unknown id. The harness exposes it as `Project.rename`.
16+
- The desktop start page creates a session in the folder you pick, or in a memory sandbox, sends your first prompt and opens the session. A memory session's project is named "In Memory".
1417

1518
## [@codeworksh/cli@0.3.0] - 2026-10-13
1619

‎apps/server/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
"typecheck": "tsc --noEmit"
1313
},
1414
"dependencies": {
15+
"@codeworksh/web": "workspace:*",
16+
"@codeworksh/widget": "workspace:*",
1517
"@effect/platform-node": "4.0.1",
1618
"@effect/sql-sqlite-node": "4.0.1",
1719
"effect": "4.0.1"

‎apps/server/src/codework.ts‎

Lines changed: 42 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,64 @@
1+
import { Codework as Mirror } from "@codeworksh/widget/codework";
2+
import { Codework as Client } from "@codeworksh/widget/backend";
13
import { NodeSocket } from "@effect/platform-node";
2-
import { Config, Effect, FileSystem, Schema } from "effect";
3-
import { Rpc, RpcClient, RpcGroup, RpcSerialization } from "effect/rpc";
4+
import { Config, Effect, FileSystem, Layer, Schedule, Schema } from "effect";
5+
import { RpcClient, RpcSerialization } from "effect/rpc";
46
/* @effect-diagnostics nodeBuiltinImport:off -- the default home sits under the OS home directory. */
57
import os from "node:os";
68
import path from "node:path";
7-
import { CodeworkUnavailable, HistoryEntry } from "./contract.ts";
9+
import { CodeworkUnavailable } from "./contract.ts";
810

9-
/*
10-
* The CodeWork server's RPC contract, mirrored for the calls this app makes. The source of truth
11-
* is packages/codework/src/server/contract.ts; keep these in step until both import a shared
12-
* schema package.
13-
*/
14-
15-
const SessionInfo = Schema.Struct({
16-
id: Schema.String,
17-
title: Schema.String,
18-
directory: Schema.String,
19-
hostDir: Schema.optionalKey(Schema.String),
20-
project: Schema.optionalKey(Schema.Struct({ id: Schema.String, name: Schema.String })),
21-
updatedAt: Schema.Finite,
22-
model: Schema.optionalKey(Schema.Struct({ provider: Schema.String, id: Schema.String })),
23-
});
24-
export type SessionInfo = typeof SessionInfo.Type;
25-
26-
/** The server's "no such session" (the harness `SessionNotFoundError`), by its wire tag. */
27-
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("SessionNotFoundError", {
28-
sessionId: Schema.String,
29-
}) {}
30-
31-
const Api = RpcGroup.make(
32-
Rpc.make("server.info", {
33-
payload: {},
34-
success: Schema.Struct({ pid: Schema.Int, version: Schema.String }),
35-
}),
36-
Rpc.make("session.list", {
37-
payload: {},
38-
success: Schema.Array(SessionInfo),
39-
}),
40-
Rpc.make("session.info", {
41-
payload: { sessionId: Schema.String },
42-
success: SessionInfo,
43-
error: SessionNotFoundError,
44-
}),
45-
Rpc.make("session.history", {
46-
payload: { sessionId: Schema.String },
47-
success: Schema.Struct({ entries: Schema.Array(HistoryEntry) }),
48-
error: SessionNotFoundError,
49-
}),
50-
);
51-
52-
const make = RpcClient.make(Api);
53-
type Client = Effect.Success<typeof make>;
11+
export type SessionInfo = Mirror.SessionInfo;
5412

55-
/** How a running server advertises itself: `<home>/service.json`, written by `codework serve --service`. */
13+
/** How a running server advertises itself: `<home>/service.json`, written by `codework serve`. */
5614
const Registration = Schema.fromJsonString(
5715
Schema.Struct({ pid: Schema.Int, version: Schema.String, url: Schema.String, port: Schema.Int }),
5816
);
5917

6018
// The same home the CLI resolves: CODEWORK_HOME_DIR, else ~/.codework.
6119
const home = Config.String("CODEWORK_HOME_DIR").pipe(Config.withDefault(path.join(os.homedir(), ".codework")));
6220

63-
const endpoint = Effect.gen(function* () {
64-
const fs = yield* FileSystem.FileSystem;
65-
const file = path.join(yield* home, "service.json");
66-
const text = yield* fs.readFileString(file);
67-
return yield* Schema.decodeUnknownEffect(Registration)(text);
68-
}).pipe(
69-
Effect.mapError(
70-
() =>
71-
new CodeworkUnavailable({
72-
message: "No CodeWork server is running. Start one with `codework service start`.",
21+
/**
22+
* The app's one connection to the CodeWork server. The registration is read on every connect, so a
23+
* server restarted on a new port is picked up without restarting this app; until one is
24+
* registered, connecting waits.
25+
*/
26+
export const layer = Layer.effect(Client, RpcClient.make(Mirror.Api, { flatten: true })).pipe(
27+
Layer.provide(RpcClient.layerProtocolSocket({ retryTransientErrors: true })),
28+
Layer.provide(
29+
Layer.unwrap(
30+
Effect.gen(function* () {
31+
const fs = yield* FileSystem.FileSystem;
32+
const file = path.join(yield* home, "service.json");
33+
const url = fs.readFileString(file).pipe(
34+
Effect.flatMap(Schema.decodeUnknownEffect(Registration)),
35+
Effect.map((registration) => registration.url),
36+
Effect.retry(Schedule.spaced("1 second")),
37+
Effect.orDie,
38+
);
39+
return NodeSocket.layerWebSocket(url);
7340
}),
41+
),
7442
),
43+
Layer.provide(RpcSerialization.layerNdjson),
44+
Layer.orDie,
7545
);
7646

7747
/**
78-
* Runs one call against the registered server over a connection opened for it. The registration
79-
* is read each time, so a restarted server (new port) is picked up without restarting this app.
80-
* The file is only a hint: the call failing is what tells a stale one apart.
48+
* One call on behalf of the app's own RPC handlers. A server that is down, or of another build (a
49+
* reply this mirror cannot decode), surfaces as `CodeworkUnavailable`.
8150
*/
82-
export const call = <A, E>(use: (client: Client) => Effect.Effect<A, E>) =>
83-
Effect.gen(function* () {
84-
const { url } = yield* endpoint;
85-
return yield* Effect.scoped(Effect.flatMap(make, use)).pipe(
86-
Effect.provide(RpcClient.layerProtocolSocket()),
87-
Effect.provide(NodeSocket.layerWebSocket(url)),
88-
Effect.provide(RpcSerialization.layerNdjson),
89-
Effect.timeout("5 seconds"),
90-
// A server down and a server of another build (a reply this mirror cannot decode) both land here.
91-
Effect.catchCause(() =>
92-
Effect.fail(
93-
new CodeworkUnavailable({
94-
message: `No usable reply from the CodeWork server at ${url}. It may be stopped, or running a build older than this app.`,
95-
}),
96-
),
51+
export const call = <A, E>(use: (client: typeof Client.Service) => Effect.Effect<A, E>) =>
52+
Client.use(use).pipe(
53+
Effect.timeout("5 seconds"),
54+
Effect.catchCause(() =>
55+
Effect.fail(
56+
new CodeworkUnavailable({
57+
message:
58+
"No usable reply from the CodeWork server. Start one with `codework service start`; it may also be running a build older than this app.",
59+
}),
9760
),
98-
);
99-
});
61+
),
62+
);
10063

10164
export * as Codework from "./codework.ts";

‎apps/server/src/contract.ts‎

Lines changed: 23 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
import { Schema } from "effect";
22
import { Rpc, RpcGroup } from "effect/rpc";
3-
4-
export const EntryKind = Schema.Literals(["file", "directory"]);
5-
export type EntryKind = typeof EntryKind.Type;
6-
7-
/** One direct child of a listed directory. */
8-
export const Entry = Schema.Struct({ name: Schema.String, kind: EntryKind });
9-
export type Entry = typeof Entry.Type;
10-
11-
export class FsError extends Schema.TaggedError<FsError>()("Server.FsError", {
12-
path: Schema.String,
13-
message: Schema.String,
14-
}) {}
3+
import { Event, EventTimeout, Publish, Request, Synced } from "@codeworksh/widget/event";
154

165
/** A conversation with the agent, as listed under its project. */
176
export const SessionSummary = Schema.Struct({
@@ -37,74 +26,6 @@ export class CodeworkUnavailable extends Schema.TaggedError<CodeworkUnavailable>
3726
message: Schema.String,
3827
}) {}
3928

40-
/*
41-
* A conversation as the CodeWork server sends it, mirrored from packages/codework/src/server
42-
* (part.ts and the `session.history` entry in contract.ts). Keep the two in step until both import
43-
* a shared schema package.
44-
*/
45-
46-
export const TextContent = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String });
47-
export const ImageContent = Schema.Struct({
48-
type: Schema.Literal("image"),
49-
data: Schema.String,
50-
mimeType: Schema.String,
51-
});
52-
export const ThinkingContent = Schema.Struct({
53-
type: Schema.Literal("thinking"),
54-
thinking: Schema.String,
55-
/** The provider withheld the reasoning; `thinking` is then empty. */
56-
redacted: Schema.optionalKey(Schema.Boolean),
57-
});
58-
59-
export const ToolStatus = Schema.Literals(["pending", "running", "completed", "error", "skipped", "aborted"]);
60-
export type ToolStatus = typeof ToolStatus.Type;
61-
62-
export const ToolResult = Schema.Struct({
63-
content: Schema.Array(Schema.Union([TextContent, ImageContent])),
64-
isError: Schema.Boolean,
65-
});
66-
67-
export const ToolCall = Schema.Struct({
68-
type: Schema.Literal("toolCall"),
69-
callID: Schema.String,
70-
name: Schema.String,
71-
arguments: Schema.Record(Schema.String, Schema.Json),
72-
status: ToolStatus,
73-
/** Present once the call settled (completed, error, skipped, aborted). */
74-
result: Schema.optionalKey(ToolResult),
75-
/** Epoch milliseconds: started, and last lifecycle update. */
76-
time: Schema.optionalKey(Schema.Struct({ start: Schema.Finite, end: Schema.Finite })),
77-
});
78-
export type ToolCall = typeof ToolCall.Type;
79-
80-
export const Part = Schema.Union([TextContent, ImageContent, ThinkingContent, ToolCall]);
81-
export type Part = typeof Part.Type;
82-
83-
/** One message on a session's active branch, oldest first. */
84-
export const HistoryEntry = Schema.Struct({
85-
id: Schema.String,
86-
seq: Schema.Int,
87-
role: Schema.Literals(["user", "assistant"]),
88-
/** `draft` is a reply still streaming; `aborted` one that was interrupted. */
89-
state: Schema.Literals(["committed", "draft", "aborted"]),
90-
/** Epoch milliseconds. */
91-
createdAt: Schema.Finite,
92-
parts: Schema.Array(Part),
93-
});
94-
export type HistoryEntry = typeof HistoryEntry.Type;
95-
96-
/** One session with its conversation. */
97-
export const Session = Schema.Struct({
98-
...SessionSummary.fields,
99-
project: Schema.optionalKey(Schema.Struct({ id: Schema.String, name: Schema.String })),
100-
entries: Schema.Array(HistoryEntry),
101-
});
102-
export type Session = typeof Session.Type;
103-
104-
export class SessionNotFound extends Schema.TaggedError<SessionNotFound>()("Server.SessionNotFound", {
105-
id: Schema.String,
106-
}) {}
107-
10829
export const Layout = Schema.Literals(["tile", "monocle"]);
10930
export type Layout = typeof Layout.Type;
11031

@@ -150,20 +71,10 @@ export class WorkspaceError extends Schema.TaggedError<WorkspaceError>()("Server
15071
const KvKey = { package: Schema.String, key: Schema.String };
15172

15273
export const Api = RpcGroup.make(
153-
Rpc.make("fs.list", {
154-
payload: { path: Schema.String },
155-
success: Schema.Array(Entry),
156-
error: FsError,
157-
}),
15874
Rpc.make("projects.list", {
15975
success: Schema.Array(Project),
16076
error: CodeworkUnavailable,
16177
}),
162-
Rpc.make("sessions.get", {
163-
payload: { id: Schema.String },
164-
success: Session,
165-
error: Schema.Union([SessionNotFound, CodeworkUnavailable]),
166-
}),
16778
Rpc.make("workspaces.list", {
16879
success: Schema.Array(Workspace),
16980
}),
@@ -196,6 +107,28 @@ export const Api = RpcGroup.make(
196107
payload: { ...KvKey, value: Schema.Json },
197108
success: Schema.Void,
198109
}),
110+
Rpc.make("event.publish", {
111+
payload: Publish,
112+
success: Event,
113+
}),
114+
/**
115+
* Publishes a request and returns its reply: the first event on the same topic whose `cause` is
116+
* the request's id. A request republished with its id returns the reply already stored.
117+
*/
118+
Rpc.make("event.request", {
119+
payload: Request,
120+
success: Event,
121+
error: EventTimeout,
122+
}),
123+
/**
124+
* Events on `topics`: those after `after` from the log, then `Synced`, then live ones. Without
125+
* `after` nothing is replayed, so `Synced` comes first and carries the current head.
126+
*/
127+
Rpc.make("event.subscribe", {
128+
payload: { topics: Schema.Array(Schema.String), after: Schema.optional(Schema.Int) },
129+
success: Schema.Union([Event, Synced]),
130+
stream: true,
131+
}),
199132
);
200133

201134
export * as Contract from "./contract.ts";

‎apps/server/src/db.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
2-
import { Clock, Effect, Layer, String as Str } from "effect";
2+
import { Clock, Effect, Layer, Schema, String as Str } from "effect";
33
import { Migrator, SqlClient } from "effect/sql";
44

55
// Migrations ship as code so they survive bundling. Keys are
@@ -45,6 +45,23 @@ const migrations = {
4545
const sql = yield* SqlClient.SqlClient;
4646
yield* sql`ALTER TABLE workspaces ADD COLUMN auto INTEGER NOT NULL DEFAULT 0`;
4747
}),
48+
"202610160002_event": Effect.gen(function* () {
49+
const sql = yield* SqlClient.SqlClient;
50+
// AUTOINCREMENT so a seq is never reused, even after the newest rows are deleted.
51+
yield* sql`
52+
CREATE TABLE event (
53+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
54+
id TEXT NOT NULL UNIQUE,
55+
topic TEXT NOT NULL,
56+
source TEXT NOT NULL,
57+
cause TEXT,
58+
payload TEXT NOT NULL,
59+
created INTEGER NOT NULL
60+
)
61+
`;
62+
yield* sql`CREATE INDEX event_topic_seq ON event (topic, seq)`;
63+
yield* sql`CREATE INDEX event_cause ON event (cause)`;
64+
}),
4865
};
4966

5067
const setup = Effect.gen(function* () {
@@ -56,6 +73,9 @@ const setup = Effect.gen(function* () {
5673
yield* SqliteMigrator.run({ loader: Migrator.fromRecord(migrations) });
5774
});
5875

76+
/** A JSON value stored as TEXT. */
77+
export const JsonText = Schema.fromJsonString(Schema.Json);
78+
5979
/** `SqlClient` for the database file at `filename`, migrated before first use. */
6080
export const layer = (filename: string) =>
6181
SqliteClient.layer({

0 commit comments

Comments
 (0)