@bumpyclock/pi-tasque 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.
@@ -0,0 +1,234 @@
1
+ import type {
2
+ ExecOptions,
3
+ ExecResult,
4
+ ExtensionAPI,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ TSQ_SCHEMA_VERSION,
8
+ type JsonValue,
9
+ type TsqEnvelope,
10
+ type TsqErr,
11
+ } from "./types.js";
12
+
13
+ export interface TsqRunContext {
14
+ readonly cwd: string;
15
+ }
16
+
17
+ export interface TsqRunOptions {
18
+ readonly timeout?: number;
19
+ readonly signal?: AbortSignal;
20
+ }
21
+
22
+ export class TsqCommandError extends Error {
23
+ override readonly name = "TsqCommandError";
24
+ readonly code: string;
25
+ readonly command: string;
26
+ readonly details?: JsonValue;
27
+ readonly envelope: TsqErr;
28
+
29
+ constructor(envelope: TsqErr) {
30
+ super(envelope.error.message);
31
+ this.code = envelope.error.code;
32
+ this.command = envelope.command;
33
+ this.envelope = envelope;
34
+ const { details } = envelope.error;
35
+ if (details !== undefined) {
36
+ this.details = details;
37
+ }
38
+ }
39
+ }
40
+
41
+ export class TsqProcessError extends Error {
42
+ override readonly name = "TsqProcessError";
43
+ readonly code: number;
44
+ readonly stderr: string;
45
+ readonly stdout: string;
46
+ readonly killed: boolean;
47
+ readonly args: readonly string[];
48
+ override readonly cause?: unknown;
49
+
50
+ constructor(result: ExecResult, args: readonly string[], cause?: unknown) {
51
+ super(buildProcessErrorMessage(result));
52
+ this.code = result.code;
53
+ this.stderr = result.stderr;
54
+ this.stdout = result.stdout;
55
+ this.killed = result.killed;
56
+ this.args = [...args];
57
+ if (cause !== undefined) {
58
+ this.cause = cause;
59
+ }
60
+ }
61
+ }
62
+
63
+ export async function runTsqJson<TData>(
64
+ pi: ExtensionAPI,
65
+ ctx: TsqRunContext,
66
+ args: readonly string[],
67
+ options: TsqRunOptions = {},
68
+ ): Promise<TData> {
69
+ const execArgs = buildJsonArgs(args);
70
+ const result = await pi.exec("tsq", execArgs, buildExecOptions(ctx, options));
71
+
72
+ if (result.killed) {
73
+ throw new TsqProcessError(result, execArgs);
74
+ }
75
+
76
+ let envelope: TsqEnvelope<unknown>;
77
+ try {
78
+ envelope = parseTsqEnvelope(result.stdout);
79
+ } catch (error) {
80
+ if (result.code !== 0) {
81
+ throw new TsqProcessError(result, execArgs, error);
82
+ }
83
+ throw error;
84
+ }
85
+
86
+ if (!envelope.ok) {
87
+ throw new TsqCommandError(envelope);
88
+ }
89
+
90
+ if (result.code !== 0) {
91
+ throw new TsqProcessError(result, execArgs);
92
+ }
93
+
94
+ return envelope.data as TData;
95
+ }
96
+
97
+ export function parseTsqEnvelope(stdout: string): TsqEnvelope<unknown> {
98
+ let parsed: unknown;
99
+ try {
100
+ parsed = JSON.parse(stdout);
101
+ } catch {
102
+ throw new Error("tsq returned invalid JSON");
103
+ }
104
+
105
+ if (!isRecord(parsed)) {
106
+ throw new Error(
107
+ "tsq returned invalid JSON envelope: root must be an object",
108
+ );
109
+ }
110
+
111
+ if (!("schema_version" in parsed)) {
112
+ throw new Error(
113
+ "tsq returned invalid JSON envelope: schema_version is required",
114
+ );
115
+ }
116
+ if (parsed.schema_version !== TSQ_SCHEMA_VERSION) {
117
+ throw new Error(
118
+ `tsq returned unsupported schema version: ${String(parsed.schema_version)}`,
119
+ );
120
+ }
121
+
122
+ if (typeof parsed.command !== "string") {
123
+ throw new Error(
124
+ "tsq returned invalid JSON envelope: command must be a string",
125
+ );
126
+ }
127
+
128
+ if (typeof parsed.ok !== "boolean") {
129
+ throw new Error("tsq returned invalid JSON envelope: ok must be boolean");
130
+ }
131
+
132
+ if (parsed.ok) {
133
+ if (!("data" in parsed)) {
134
+ throw new Error(
135
+ "tsq returned invalid JSON envelope: data is required when ok is true",
136
+ );
137
+ }
138
+ return parsed as unknown as TsqEnvelope<unknown>;
139
+ }
140
+
141
+ if (!isRecord(parsed.error)) {
142
+ throw new Error(
143
+ "tsq returned invalid JSON envelope: error is required when ok is false",
144
+ );
145
+ }
146
+ if (typeof parsed.error.code !== "string") {
147
+ throw new Error(
148
+ "tsq returned invalid JSON envelope: error.code must be a string",
149
+ );
150
+ }
151
+ if (typeof parsed.error.message !== "string") {
152
+ throw new Error(
153
+ "tsq returned invalid JSON envelope: error.message must be a string",
154
+ );
155
+ }
156
+
157
+ return parsed as unknown as TsqEnvelope<unknown>;
158
+ }
159
+
160
+ function buildJsonArgs(args: readonly string[]): string[] {
161
+ let hasJsonFormat = false;
162
+ const separatorIndex = args.indexOf("--");
163
+ const optionEndIndex = separatorIndex === -1 ? args.length : separatorIndex;
164
+
165
+ for (let index = 0; index < optionEndIndex; index += 1) {
166
+ const arg = args[index];
167
+ if (arg === undefined) {
168
+ continue;
169
+ }
170
+ if (arg === "--format") {
171
+ const value = args[index + 1];
172
+ if (value === "json") {
173
+ hasJsonFormat = true;
174
+ index += 1;
175
+ continue;
176
+ }
177
+ const received =
178
+ value === undefined ? "--format without a value" : `--format ${value}`;
179
+ throw new Error(buildFormatError(received));
180
+ }
181
+
182
+ if (arg.startsWith("--format=")) {
183
+ const value = arg.slice("--format=".length);
184
+ if (value === "json") {
185
+ hasJsonFormat = true;
186
+ continue;
187
+ }
188
+ throw new Error(buildFormatError(arg));
189
+ }
190
+ }
191
+
192
+ if (hasJsonFormat) {
193
+ return [...args];
194
+ }
195
+
196
+ if (separatorIndex === -1) {
197
+ return [...args, "--format", "json"];
198
+ }
199
+
200
+ return [
201
+ ...args.slice(0, separatorIndex),
202
+ "--format",
203
+ "json",
204
+ ...args.slice(separatorIndex),
205
+ ];
206
+ }
207
+
208
+ function buildFormatError(received: string): string {
209
+ return `runTsqJson requires JSON format output; received ${received}`;
210
+ }
211
+
212
+ function buildExecOptions(
213
+ ctx: TsqRunContext,
214
+ options: TsqRunOptions,
215
+ ): ExecOptions {
216
+ return {
217
+ cwd: ctx.cwd,
218
+ ...(options.timeout === undefined ? {} : { timeout: options.timeout }),
219
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
220
+ };
221
+ }
222
+
223
+ function buildProcessErrorMessage(result: ExecResult): string {
224
+ const summary = result.stderr.trim() || result.stdout.trim();
225
+ const killed = result.killed ? " (killed)" : "";
226
+ if (summary.length === 0) {
227
+ return `tsq failed with exit code ${result.code}${killed}`;
228
+ }
229
+ return `tsq failed with exit code ${result.code}${killed}: ${summary}`;
230
+ }
231
+
232
+ function isRecord(value: unknown): value is Record<string, unknown> {
233
+ return typeof value === "object" && value !== null && !Array.isArray(value);
234
+ }
@@ -0,0 +1,184 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ createTasqueStatusCache,
7
+ formatTasqueStatusText,
8
+ refreshTasqueStatusCache,
9
+ type TasqueStatusCache,
10
+ } from "./cache.js";
11
+
12
+ export const TASQUE_STATUS_KEY = "pi-tasque";
13
+
14
+ const MUTATING_TOOL_NAMES = new Set(["tsq_change", "tsq_claim", "task_bridge"]);
15
+ const DEFAULT_INTERVAL_MS = 60_000;
16
+
17
+ export interface TasqueStatusLifecycleOptions {
18
+ readonly intervalMs?: number;
19
+ readonly refreshTimeoutMs?: number;
20
+ readonly now?: () => number;
21
+ readonly staleAfterMs?: number;
22
+ }
23
+
24
+ interface ToolExecutionEndLike {
25
+ readonly isError: boolean;
26
+ readonly toolName: string;
27
+ readonly result: unknown;
28
+ }
29
+
30
+ export function registerTasqueStatusLifecycle(
31
+ pi: ExtensionAPI,
32
+ options: TasqueStatusLifecycleOptions = {},
33
+ ): void {
34
+ let cache: TasqueStatusCache = createTasqueStatusCache();
35
+ let interval: ReturnType<typeof setInterval> | undefined;
36
+ let latestContext: ExtensionContext | undefined;
37
+ let refreshInFlight: Promise<void> | undefined;
38
+ let refreshInFlightGeneration: number | undefined;
39
+ let queuedRefreshContext: ExtensionContext | undefined;
40
+ let lifecycleGeneration = 0;
41
+ let statusActive = true;
42
+
43
+ async function refresh(ctx: ExtensionContext): Promise<void> {
44
+ if (!statusActive || !hasStatusUi(ctx)) {
45
+ return;
46
+ }
47
+
48
+ latestContext = ctx;
49
+ const generation = lifecycleGeneration;
50
+ if (
51
+ refreshInFlight !== undefined &&
52
+ refreshInFlightGeneration === generation
53
+ ) {
54
+ queuedRefreshContext = ctx;
55
+ return refreshInFlight;
56
+ }
57
+
58
+ let refreshPromise: Promise<void>;
59
+ refreshPromise = refreshTasqueStatusCache(pi, { cwd: ctx.cwd }, cache, {
60
+ ...(options.now === undefined ? {} : { now: options.now }),
61
+ ...(options.refreshTimeoutMs === undefined
62
+ ? {}
63
+ : { timeout: options.refreshTimeoutMs }),
64
+ })
65
+ .then((nextCache) => {
66
+ if (!statusActive || generation !== lifecycleGeneration) {
67
+ return;
68
+ }
69
+ cache = nextCache;
70
+ ctx.ui.setStatus(
71
+ TASQUE_STATUS_KEY,
72
+ formatTasqueStatusText(cache.state, {
73
+ ...(options.now === undefined ? {} : { now: options.now }),
74
+ ...(options.staleAfterMs === undefined
75
+ ? {}
76
+ : { staleAfterMs: options.staleAfterMs }),
77
+ }),
78
+ );
79
+ })
80
+ .finally(async () => {
81
+ const isCurrentRefresh =
82
+ refreshInFlight === refreshPromise &&
83
+ refreshInFlightGeneration === generation;
84
+ if (isCurrentRefresh) {
85
+ refreshInFlight = undefined;
86
+ refreshInFlightGeneration = undefined;
87
+ }
88
+ if (
89
+ !isCurrentRefresh ||
90
+ !statusActive ||
91
+ generation !== lifecycleGeneration
92
+ ) {
93
+ return;
94
+ }
95
+ const nextContext = queuedRefreshContext;
96
+ queuedRefreshContext = undefined;
97
+ if (nextContext === undefined) {
98
+ return;
99
+ }
100
+ await refresh(nextContext);
101
+ });
102
+
103
+ refreshInFlight = refreshPromise;
104
+ refreshInFlightGeneration = generation;
105
+ return refreshPromise;
106
+ }
107
+
108
+ function clearRefreshInterval(): void {
109
+ if (interval !== undefined) {
110
+ clearInterval(interval);
111
+ interval = undefined;
112
+ }
113
+ }
114
+
115
+ pi.on("session_start", async (_event, ctx) => {
116
+ clearRefreshInterval();
117
+ if (!hasStatusUi(ctx)) {
118
+ return;
119
+ }
120
+ lifecycleGeneration += 1;
121
+ refreshInFlight = undefined;
122
+ refreshInFlightGeneration = undefined;
123
+ queuedRefreshContext = undefined;
124
+ statusActive = true;
125
+ latestContext = ctx;
126
+ interval = setInterval(() => {
127
+ const ctxForRefresh = latestContext;
128
+ if (ctxForRefresh !== undefined) {
129
+ void refresh(ctxForRefresh);
130
+ }
131
+ }, options.intervalMs ?? DEFAULT_INTERVAL_MS);
132
+ await refresh(ctx);
133
+ });
134
+
135
+ pi.on("tool_execution_end", async (event, ctx) => {
136
+ if (!shouldRefreshAfterTool(event) || !hasStatusUi(ctx)) {
137
+ return;
138
+ }
139
+ await refresh(ctx);
140
+ });
141
+
142
+ pi.on("session_shutdown", (_event, ctx) => {
143
+ clearRefreshInterval();
144
+ lifecycleGeneration += 1;
145
+ latestContext = undefined;
146
+ refreshInFlight = undefined;
147
+ refreshInFlightGeneration = undefined;
148
+ queuedRefreshContext = undefined;
149
+ statusActive = false;
150
+ if (hasStatusUi(ctx)) {
151
+ ctx.ui.setStatus(TASQUE_STATUS_KEY, undefined);
152
+ }
153
+ });
154
+ }
155
+
156
+ function shouldRefreshAfterTool(event: ToolExecutionEndLike): boolean {
157
+ if (event.isError || !MUTATING_TOOL_NAMES.has(event.toolName)) {
158
+ return false;
159
+ }
160
+ return getDetailsOk(event.result) !== false;
161
+ }
162
+
163
+ function getDetailsOk(result: unknown): boolean | undefined {
164
+ if (!isRecord(result)) {
165
+ return undefined;
166
+ }
167
+ const details = result.details;
168
+ if (!isRecord(details)) {
169
+ return undefined;
170
+ }
171
+ return typeof details.ok === "boolean" ? details.ok : undefined;
172
+ }
173
+
174
+ function hasStatusUi(ctx: ExtensionContext): boolean {
175
+ return (
176
+ ctx.hasUI === true &&
177
+ isRecord(ctx.ui) &&
178
+ typeof ctx.ui.setStatus === "function"
179
+ );
180
+ }
181
+
182
+ function isRecord(value: unknown): value is Record<string, unknown> {
183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
184
+ }