Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
798742e
fix(studio): refine account recovery and presentation
RyanNg1403 Sep 18, 2026
a49c99c
fix(studio): make Desktop thread handoff deterministic
RyanNg1403 Sep 18, 2026
b88d546
Merge pull request #57 from RyanNg1403/chore/studio-account-recovery
RyanNg1403 Sep 18, 2026
555d37d
Merge pull request #59 from RyanNg1403/main
RyanNg1403 Sep 18, 2026
931b04b
feat: expose H5P media metadata
RyanNg1403 Sep 19, 2026
577b141
Merge pull request #60 from RyanNg1403/fix/h5p-resource-support
RyanNg1403 Sep 19, 2026
81675b8
fix(studio): reclaim released Desktop threads
RyanNg1403 Sep 19, 2026
0c7740a
fix(mcp): disambiguate resource references
RyanNg1403 Sep 19, 2026
41ebf60
fix(studio): stabilize mobile select focus
RyanNg1403 Sep 19, 2026
6d06333
feat(studio): compact the message composer
RyanNg1403 Sep 19, 2026
69f2d9e
fix(studio): simplify composer attachment control
RyanNg1403 Sep 19, 2026
ab4cd92
fix(studio): align composer scrolling with outer edge
RyanNg1403 Sep 19, 2026
1a26eea
chore: remove flaky Studio UI tests
RyanNg1403 Sep 19, 2026
bca242e
Merge pull request #61 from RyanNg1403/fix/studio-desktop-lock-reclaim
RyanNg1403 Sep 19, 2026
7acf97a
fix(studio): isolate computer-use tools from Studio threads
RyanNg1403 Sep 19, 2026
9b29c6b
Merge pull request #62 from RyanNg1403/fix/studio-desktop-lock-reclaim
RyanNg1403 Sep 19, 2026
3da303c
fix(studio): stop active turns when page closes
RyanNg1403 Sep 19, 2026
2fef5d1
Merge pull request #63 from RyanNg1403/fix/studio-turn-lifecycle
RyanNg1403 Sep 19, 2026
e2465b0
fix(renderer): hide Codex control markers
RyanNg1403 Sep 19, 2026
042f92c
Merge pull request #64 from RyanNg1403/fix/studio-render-control-markers
RyanNg1403 Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,16 @@

## UIT Studio

Your course space. Built to focus.

Both installation methods launch the same local web Studio with `uit-studio`.
The npm installation requires [Node.js 24.0+](https://nodejs.org/); native
release packages include Node.js and the SSO Chromium runtime.

See what's due. Find what you need. Get back to learning.

Track deadlines, announcements, and course materials in one focused workspace.

Calendar keeps the next deadline visible. Announcements keep you in the loop. Reminders keep you ahead.
<p align="center">
<img src="docs/assets/uit-studio-panda.png" alt="UIT Studio panda mascot" width="180">
</p>

Studio runs locally in the background. Run `uit-studio stop` whenever you want to shut it down.
- **Your course space. Built to focus.**
- Both installation methods launch the same local web Studio with `uit-studio`. The npm installation requires [Node.js 24.0+](https://nodejs.org/); native release packages include Node.js and the SSO Chromium runtime.
- **See what's due. Find what you need. Get back to learning.**
- Track deadlines, announcements, and course materials in one focused workspace.
- Calendar keeps the next deadline visible. Announcements keep you in the loop. Reminders keep you ahead.
- Studio runs locally in the background. Run `uit-studio stop` whenever you want to shut it down.

<details>
<summary>macOS</summary>
Expand Down
Binary file added docs/assets/uit-studio-panda.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion scripts/check-studio-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ for (const required of [
"dist/assignment-submission.js",
"dist/calendar.js",
"dist/desktop-service.js",
"dist/h5p.js",
"dist/mcp-server.js",
"dist/mcp-entry.js",
"dist/session-health.js",
Expand All @@ -66,9 +67,10 @@ for (const required of [
"dist/studio-web-launcher.js",
"dist/uit-tools.js",
"studio-build/renderer/index.html",
"studio-build/renderer/hidden-markup.js",
"studio-build/renderer/renderer.js",
"studio-build/renderer/calendar.js",
"studio-build/renderer/assets/uit-dau-dau-icon.png"
"studio-build/renderer/assets/uit-dau-dau.svg"
]) {
if (!runtimeFiles.includes(required)) throw new Error(`uit-runtime package is missing ${required}`);
}
Expand Down
1 change: 1 addition & 0 deletions scripts/prepare-runtime-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const sharedModules = [
"commands",
"config",
"desktop-service",
"h5p",
"mcp-server",
"mcp-entry",
"studio-core",
Expand Down
119 changes: 112 additions & 7 deletions src/codex-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type CodexDynamicToolSpec = CodexDynamicToolFunction | {
};

export interface CodexThreadStartOptions {
/** Configuration overrides applied only while this thread is loaded here. */
config?: Record<string, CodexJsonValue>;
dynamicTools?: CodexDynamicToolSpec[];
model?: string;
approvalPolicy?: "on-request" | "never";
Expand Down Expand Up @@ -94,9 +96,30 @@ export interface CodexAccountReadResult {
}

export interface CodexThreadResumeOptions {
/** Configuration overrides applied only while this thread is loaded here. */
config?: Record<string, CodexJsonValue>;
excludeTurns?: boolean;
}

export class CodexRpcError extends Error {
constructor(
readonly method: string,
readonly code: number | undefined,
readonly data: unknown,
message: string
) {
super(message);
this.name = "CodexRpcError";
}
}

export function isCodexThreadNotFoundError(error: unknown, method: string, threadId: string): boolean {
return error instanceof CodexRpcError
&& error.method === method
&& error.code === -32600
&& error.message === `thread not loaded: ${threadId}`;
}

export interface CodexTurn {
id: string;
status?: string;
Expand Down Expand Up @@ -146,6 +169,14 @@ function parseThreadResumeResult(value: unknown): CodexThread {
return { ...value.thread, id: value.thread.id, status };
}

function parseThreadReadResult(value: unknown): CodexThread {
if (!isRecord(value)) throw new Error("Malformed thread/read response: result must be an object.");
if (!isRecord(value.thread) || typeof value.thread.id !== "string" || value.thread.id.trim() === "") {
throw new Error("Malformed thread/read response: result.thread.id must be a non-empty string.");
}
return { ...value.thread, id: value.thread.id };
}

function parseThreadStatusChangedParams(value: unknown): { threadId: string; status: CodexThreadStatus } {
if (!isRecord(value) || typeof value.threadId !== "string" || value.threadId.trim() === "") {
throw new Error("Malformed thread/status/changed notification: params.threadId must be a non-empty string.");
Expand All @@ -166,7 +197,8 @@ export class CodexClient extends EventEmitter {
private nextId = 1;
private connected = false;
private connecting: Promise<Record<string, unknown>> | undefined;
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private disconnecting: Promise<void> | undefined;
private pending = new Map<number, { method: string; resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private serverRequests = new Set<CodexRequestId>();

constructor(options: CodexClientOptions = {}) {
Expand All @@ -183,6 +215,7 @@ export class CodexClient extends EventEmitter {
}

connect(): Promise<Record<string, unknown>> {
if (this.disconnecting) return this.disconnecting.then(() => this.connect());
if (this.connecting) return this.connecting;
if (this.connected && this.process) return Promise.resolve({});
let process: ChildProcessWithoutNullStreams | undefined;
Expand Down Expand Up @@ -241,6 +274,7 @@ export class CodexClient extends EventEmitter {
serviceName: "uit_studio",
sandbox: "workspace-write",
approvalPolicy: "on-request",
...(options.config !== undefined ? { config: options.config } : {}),
...(options.model !== undefined ? { model: options.model } : {}),
...(options.approvalPolicy !== undefined ? { approvalPolicy: options.approvalPolicy } : {}),
...(options.dynamicTools !== undefined ? { dynamicTools: options.dynamicTools } : {})
Expand Down Expand Up @@ -283,11 +317,18 @@ export class CodexClient extends EventEmitter {
await this.connect();
const result = await this.request("thread/resume", {
threadId,
...(options.config !== undefined ? { config: options.config } : {}),
...(options.excludeTurns !== undefined ? { excludeTurns: options.excludeTurns } : {})
});
return parseThreadResumeResult(result);
}

async readThread(threadId: string): Promise<CodexThread> {
await this.connect();
const result = await this.request("thread/read", { threadId, includeTurns: false });
return parseThreadReadResult(result);
}

async startTurn(threadId: string, text: string, cwd?: string, options: CodexTurnStartOptions = {}): Promise<CodexTurn> {
await this.connect();
const result = await this.request("turn/start", {
Expand Down Expand Up @@ -332,8 +373,70 @@ export class CodexClient extends EventEmitter {
}

async disconnect(): Promise<void> {
await this.disconnectInternal(false);
}

/**
* Disconnect and wait until the child process has actually exited.
*
* This is required before handing a thread to another app-server: the
* rollout store permits only one active writer for a thread.
*/
async disconnectAndWait(): Promise<void> {
await this.disconnectInternal(true);
}

private async disconnectInternal(waitForExit: boolean): Promise<void> {
if (this.disconnecting) {
if (waitForExit) await this.disconnecting;
return;
}
this.connecting = undefined;
if (this.process) this.closeProcess(this.process, new Error("Codex client disconnected"));
const process = this.process;
if (!process) return;
if (!waitForExit) {
this.closeProcess(process, new Error("Codex client disconnected"));
return;
}
const exited = this.waitForProcessExit(process);
const disconnection = exited.finally(() => {
if (this.disconnecting === disconnection) this.disconnecting = undefined;
});
this.disconnecting = disconnection;
this.closeProcess(process, new Error("Codex client disconnected"));
await disconnection;
}

private waitForProcessExit(process: ChildProcessWithoutNullStreams): Promise<void> {
return new Promise((resolveExit, rejectExit) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
process.removeListener("close", onExit);
process.removeListener("error", onError);
rejectExit(new Error("Codex app-server did not exit after disconnect."));
}, 5_000);
timer.unref();
const onExit = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
process.removeListener("close", onExit);
process.removeListener("error", onError);
resolveExit();
};
const onError = (error: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
process.removeListener("close", onExit);
process.removeListener("error", onError);
rejectExit(error);
};
process.once("close", onExit);
process.once("error", onError);
});
}

private request(method: string, params: Record<string, unknown>): Promise<any> {
Expand All @@ -345,7 +448,7 @@ export class CodexClient extends EventEmitter {
this.pending.delete(id);
reject(new Error(`Codex request timed out: ${method}`));
}, this.requestTimeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.pending.set(id, { method, resolve, reject, timer });
try {
this.write({ method, id, params });
} catch (error) {
Expand Down Expand Up @@ -411,10 +514,12 @@ export class CodexClient extends EventEmitter {
}
clearTimeout(pending.timer);
this.pending.delete(message.id);
if (message.error) pending.reject(Object.assign(new Error(message.error.message || "Codex request failed"), {
code: message.error.code,
data: message.error.data
}));
if (message.error) pending.reject(new CodexRpcError(
pending.method,
message.error.code,
message.error.data,
message.error.message || "Codex request failed"
));
else pending.resolve(message.result);
return;
}
Expand Down
40 changes: 14 additions & 26 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createInterface } from "node:readline/promises";
import { Writable } from "node:stream";
import { defaultApiClient } from "./api.js";
import { get, save } from "./config.js";
import { listH5pActivities, readH5pActivity } from "./h5p.js";
import type { ApiClient, MoodleRecord } from "./types.js";
import { extractH5pPackage } from "./unzip.js";
import { submitAssignmentFile } from "./assignment-submission.js";
Expand Down Expand Up @@ -392,13 +393,16 @@ async function viewForum(moduleId: number, instance: number | undefined, ctx: Co

async function viewH5p(moduleId: number, courseId: number, name: string, ctx: CommandContext): Promise<void> {
let files: MoodleRecord[] = [];
let h5p: Awaited<ReturnType<typeof readH5pActivity>>["content"] | undefined;
let note: string | undefined;
try {
files = (await fetchH5pPackages(courseId, ctx)).get(moduleId) || [];
const result = await readH5pActivity(courseId, moduleId, ctx.api);
files = result.activity.files;
h5p = result.content;
} catch (error) {
note = `Could not load H5P package: ${error instanceof Error ? error.message : String(error)}`;
note = `Could not read H5P content: ${error instanceof Error ? error.message : String(error)}`;
}
const data: MoodleRecord = { module_id: moduleId, type: "h5pactivity", name, files };
const data: MoodleRecord = { module_id: moduleId, type: "h5pactivity", name, files, h5p };
if (note) data.note = note;
if (isJsonMode()) {
console.log(JSON.stringify(data, null, 2));
Expand All @@ -407,8 +411,11 @@ async function viewH5p(moduleId: number, courseId: number, name: string, ctx: Co
console.log(`[h5pactivity] ${name}`);
console.log(`module_id: ${moduleId}\n`);
for (const file of files) console.log(` ${file.filename} (${formatSize(file.filesize || 0)})`);
for (const entry of h5p?.entries || []) {
console.log(`\n${entry.position}. ${entry.title}`);
for (const media of entry.media) console.log(` [${media.kind}] ${media.url || media.packagePath}`);
}
if (note) console.log(` ${note}`);
if (files.length) console.log(`\nTip: uit download ${courseId} --module ${moduleId} (add --extract to unpack media)`);
}

async function viewResource(moduleId: number, courseId: number, name: string, ctx: CommandContext): Promise<void> {
Expand Down Expand Up @@ -693,27 +700,6 @@ export async function cmdAnnouncements(args: { course_id: number; limit?: number
}
}

// H5P activities expose no files through core_course_get_contents; their .h5p
// package lives behind a dedicated web service, keyed by module ID (coursemodule).
async function fetchH5pPackages(courseId: number, ctx: CommandContext): Promise<Map<number, MoodleRecord[]>> {
const packages = new Map<number, MoodleRecord[]>();
const response = await ctx.api.call<MoodleRecord>("mod_h5pactivity_get_h5pactivities_by_courses", {
"courseids[0]": courseId
});
for (const activity of response.h5pactivities || []) {
const files = (activity.package || [])
.filter((file: MoodleRecord) => file.fileurl)
.map((file: MoodleRecord) => ({
filename: file.filename,
fileurl: file.fileurl,
filesize: file.filesize || 0,
filepath: file.filepath || "/"
}));
if (files.length) packages.set(activity.coursemodule, files);
}
return packages;
}

export async function cmdDownload(
args: { course_id: number; output?: string; module?: number; file?: string; force?: boolean; extract?: boolean },
ctx = createContext()
Expand All @@ -740,7 +726,9 @@ export async function cmdDownload(
let h5pPackages = new Map<number, MoodleRecord[]>();
if (hasH5p) {
try {
h5pPackages = await fetchH5pPackages(courseId, ctx);
h5pPackages = new Map(
[...(await listH5pActivities(courseId, ctx.api))].map(([moduleId, activity]) => [moduleId, activity.files])
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warnings.push(`Could not load H5P activity packages: ${message}`);
Expand Down
10 changes: 9 additions & 1 deletion src/desktop-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { submitAssignmentFile } from "./assignment-submission.js";
import { activateSession as selectActiveSession, get, save } from "./config.js";
import { requestMobileToken } from "./commands.js";
import { CodexClient } from "./codex-client.js";
import { readH5pActivity, type H5pContentSummary } from "./h5p.js";
import type { ApiClient, MoodleRecord } from "./types.js";
export { calendarMonth, listCalendarEvents, addAssignmentIntervals, calendarReminders } from "./calendar.js";

Expand Down Expand Up @@ -722,6 +723,7 @@ export interface ResolvedCourseResource {
description: string;
url?: string;
files?: CourseFile[];
h5p?: H5pContentSummary;
unavailable?: Record<string, string>;
}

Expand Down Expand Up @@ -763,6 +765,7 @@ export async function resolveCourseResource(courseId: number, reference: CourseR
if (module && module.id === reference.id) {
let files = module.files;
let description = module.description || "";
let h5p: H5pContentSummary | undefined;
let unavailable = module.unavailable;
if (module.modname === "assign") {
try {
Expand All @@ -774,6 +777,11 @@ export async function resolveCourseResource(courseId: number, reference: CourseR
if (!/^(?:invalidfunction|cannotfindfunction|wsfunctionnotavailable)$/.test(code || "") &&
(code !== undefined || !/^This UIT site does not expose mod_assign_get_assignments to the SSO session\./.test(String((error as Error)?.message)))) throw error;
}
} else if (module.modname === "h5pactivity" && reference.kind === "module") {
const resolved = await readH5pActivity(courseId, module.id, api);
files = filesFrom(files, resolved.activity.files);
description = resolved.activity.description || description;
h5p = resolved.content;
} else if (module.modname === "forum" && reference.kind === "file") {
const announcements = (await listAnnouncements(courseId, api)).filter((item) => item.moduleId === module.id);
files = filesFrom(files, ...announcements.map((item) => item.files));
Expand All @@ -783,7 +791,7 @@ export async function resolveCourseResource(courseId: number, reference: CourseR
if (reference.kind === "file") {
const file = selectFile(files);
if (file) resource = { kind: "file", id: reference.id, moduleId: module.id, name: file.filename, description, url: file.fileurl, files: [file], unavailable };
} else resource = { kind: "module", id: module.id, moduleId: module.id, name: module.name, description, url: module.url, files, unavailable };
} else resource = { kind: "module", id: module.id, moduleId: module.id, name: module.name, description, url: module.url, files, h5p, unavailable };
}
if (!resource && reference.kind === "file" && (fileUrl !== undefined || reference.filename !== undefined)) {
try {
Expand Down
Loading
Loading