dsh-plugin-jules 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.
- package/LICENSE +21 -0
- package/README.md +335 -0
- package/cordis.patch.yml +37 -0
- package/lib/async.d.ts +11 -0
- package/lib/async.js +27 -0
- package/lib/client.d.ts +207 -0
- package/lib/client.js +368 -0
- package/lib/index.d.ts +107 -0
- package/lib/index.js +234 -0
- package/lib/journal.d.ts +78 -0
- package/lib/journal.js +110 -0
- package/lib/tools.d.ts +43 -0
- package/lib/tools.js +576 -0
- package/lib/types.d.ts +230 -0
- package/lib/types.js +128 -0
- package/lib/views.d.ts +240 -0
- package/lib/views.js +438 -0
- package/lib/watch.d.ts +87 -0
- package/lib/watch.js +387 -0
- package/package.json +86 -0
- package/scripts/build.mjs +37 -0
- package/scripts/link-dsh-deps.mjs +132 -0
package/lib/watch.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background watching for Jules sessions.
|
|
3
|
+
*
|
|
4
|
+
* A Jules session runs for minutes in Google's cloud, so a turn that blocks on
|
|
5
|
+
* one wastes the model's time. `jules_watch` instead registers the wait with
|
|
6
|
+
* `ctx.jobs`: the call returns a job id immediately, the harness keeps polling
|
|
7
|
+
* out of band, and `dsh-tool-jobs` delivers a completion notice to the owning
|
|
8
|
+
* agent when the session finishes, fails, or stops to ask something. The model
|
|
9
|
+
* reads the report with `job_output` on a later step.
|
|
10
|
+
*
|
|
11
|
+
* The session state alone is not a completion signal. Jules can post its final
|
|
12
|
+
* answer as an `agentMessaged` activity and then leave `state` sitting at
|
|
13
|
+
* `IN_PROGRESS` indefinitely, so the watcher treats the append-only activity
|
|
14
|
+
* log as authoritative and settles on what the agent did, not only on what the
|
|
15
|
+
* state field says.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-plugin-jules/watch
|
|
18
|
+
*/
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
20
|
+
import { sleep } from "./async.js";
|
|
21
|
+
import { JulesError, JulesNotFoundError } from "./client.js";
|
|
22
|
+
import { DEFAULT_WAIT_STATES, needsAttentionState, sessionIdOf, settledState } from "./types.js";
|
|
23
|
+
import { renderWait, sessionDetail } from "./views.js";
|
|
24
|
+
/** Attempts allowed for a read that may race session creation. */
|
|
25
|
+
const VISIBILITY_ATTEMPTS = 4;
|
|
26
|
+
/**
|
|
27
|
+
* First delay between visibility attempts, in milliseconds. The wait is short
|
|
28
|
+
* on purpose: the race resolves in about a second in practice, and this same
|
|
29
|
+
* bound is what a genuinely bad session id costs before it reports.
|
|
30
|
+
*/
|
|
31
|
+
const VISIBILITY_DELAY_MS = 500;
|
|
32
|
+
/**
|
|
33
|
+
* Read something that may not be visible yet.
|
|
34
|
+
*
|
|
35
|
+
* A session created moments ago answers `Requested entity was not found` for its
|
|
36
|
+
* first read: Jules mints the id before the resource is queryable, which is why
|
|
37
|
+
* the official SDK retries the first request on a 404. Only a not-found is
|
|
38
|
+
* retried, and only while it keeps saying so — a genuinely bad id still reports
|
|
39
|
+
* once the attempts are spent.
|
|
40
|
+
* @param operation - the read to attempt.
|
|
41
|
+
* @param signal - task-owned cancellation signal.
|
|
42
|
+
* @returns the operation's result.
|
|
43
|
+
* @throws the last failure once the attempts are exhausted, or any other error at once.
|
|
44
|
+
*/
|
|
45
|
+
async function readWhenVisible(operation, signal) {
|
|
46
|
+
let lastError;
|
|
47
|
+
for (let attempt = 1; attempt <= VISIBILITY_ATTEMPTS; attempt += 1) {
|
|
48
|
+
try {
|
|
49
|
+
return await operation();
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (!(error instanceof JulesNotFoundError))
|
|
53
|
+
throw error;
|
|
54
|
+
lastError = error;
|
|
55
|
+
if (attempt === VISIBILITY_ATTEMPTS)
|
|
56
|
+
break;
|
|
57
|
+
await sleep(VISIBILITY_DELAY_MS * attempt, signal);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw lastError;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Stable identity of one activity, for de-duplicating a re-read page.
|
|
64
|
+
* @param activity - one activity from the log.
|
|
65
|
+
* @returns a key that is stable across reads of the same activity.
|
|
66
|
+
*/
|
|
67
|
+
function activityKey(activity) {
|
|
68
|
+
return activity.id ?? activity.name ?? `${activity.createTime ?? ''}:${activity.originator ?? ''}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read one page without re-recording what an earlier page already produced.
|
|
72
|
+
* @param collected - the running log for this watch, appended in place.
|
|
73
|
+
* @param seen - identities already recorded.
|
|
74
|
+
* @param page - the newest page from the service.
|
|
75
|
+
* @returns only the activities this watch had not seen before.
|
|
76
|
+
*/
|
|
77
|
+
function absorb(collected, seen, page) {
|
|
78
|
+
const fresh = [];
|
|
79
|
+
for (const activity of page) {
|
|
80
|
+
const key = activityKey(activity);
|
|
81
|
+
if (seen.has(key))
|
|
82
|
+
continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
collected.push(activity);
|
|
85
|
+
fresh.push(activity);
|
|
86
|
+
}
|
|
87
|
+
return fresh;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The newest `createTime` in a set of activities, used as the next cursor.
|
|
91
|
+
* @param activities - activities already collected.
|
|
92
|
+
* @returns the newest timestamp, or undefined when none carries one.
|
|
93
|
+
*/
|
|
94
|
+
function newestTime(activities) {
|
|
95
|
+
let newest;
|
|
96
|
+
for (const activity of activities) {
|
|
97
|
+
const time = activity.createTime;
|
|
98
|
+
if (time !== undefined && (newest === undefined || time > newest))
|
|
99
|
+
newest = time;
|
|
100
|
+
}
|
|
101
|
+
return newest;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Poll one session until the agent hands something back, then describe it.
|
|
105
|
+
*
|
|
106
|
+
* Separated from the job adapter so the settling rules — which states end a
|
|
107
|
+
* watch, which activities end it, when the budget expires, and what the report
|
|
108
|
+
* contains — are testable without a live agent owner or a real job registry.
|
|
109
|
+
* @param client - the configured Jules client.
|
|
110
|
+
* @param session - bare session id.
|
|
111
|
+
* @param targets - states that end the watch.
|
|
112
|
+
* @param budgetMs - total watch budget.
|
|
113
|
+
* @param config - polling rules.
|
|
114
|
+
* @param signal - task-owned cancellation signal.
|
|
115
|
+
* @returns why the watch stopped and the rendered report.
|
|
116
|
+
* @throws whatever the client throws, including cancellation.
|
|
117
|
+
*/
|
|
118
|
+
export async function runWatch(client, session, targets, budgetMs, config, signal) {
|
|
119
|
+
const started = Date.now();
|
|
120
|
+
let polls = 0;
|
|
121
|
+
let trigger = 'state';
|
|
122
|
+
// One cursor over an append-only log: the first read establishes it and each
|
|
123
|
+
// later poll asks only for what is new. The cursor is a bandwidth
|
|
124
|
+
// optimisation, so identity de-duplication carries correctness on its own —
|
|
125
|
+
// a rejected or ignored cursor costs requests, never a wrong answer.
|
|
126
|
+
const collected = [];
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
absorb(collected, seen, await readWhenVisible(() => client.listAllActivities(session, {
|
|
129
|
+
signal,
|
|
130
|
+
maxPages: config.maxActivityPages,
|
|
131
|
+
pageSize: 100,
|
|
132
|
+
}), signal));
|
|
133
|
+
let cursor = newestTime(collected);
|
|
134
|
+
let cursorUsable = true;
|
|
135
|
+
let current = await readWhenVisible(() => client.getSession(session, signal), signal);
|
|
136
|
+
polls += 1;
|
|
137
|
+
let previousState = current.state ?? 'STATE_UNSPECIFIED';
|
|
138
|
+
// Only what happens after the watch starts is news. The baseline is kept for
|
|
139
|
+
// the report but never settles the watch on its own: the caller who just
|
|
140
|
+
// answered the agent's question does not need it reported back.
|
|
141
|
+
let fresh = [];
|
|
142
|
+
for (;;) {
|
|
143
|
+
const state = current.state ?? 'STATE_UNSPECIFIED';
|
|
144
|
+
// Terminal means nothing more will ever arrive, so report it at once even
|
|
145
|
+
// when the watch started after the fact.
|
|
146
|
+
if (settledState(state)) {
|
|
147
|
+
trigger = 'state';
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
// An attention state settles the watch only when the session moved into it.
|
|
151
|
+
// Settling on the state it started in would make the ordinary
|
|
152
|
+
// "answer the agent, then watch for its reply" flow return instantly on the
|
|
153
|
+
// stale pre-answer state, which is no news at all.
|
|
154
|
+
if (targets.has(state) && state !== previousState) {
|
|
155
|
+
trigger = 'state';
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
// Terminal activities outrank everything: the log is the authority for what
|
|
159
|
+
// actually happened, and a failed session must never wait out its budget.
|
|
160
|
+
if (collected.some(activity => activity.sessionFailed !== undefined)) {
|
|
161
|
+
trigger = 'failed';
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (collected.some(activity => activity.sessionCompleted !== undefined)) {
|
|
165
|
+
trigger = 'completed';
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
// A message posted since the watch began: the agent has handed something
|
|
169
|
+
// back, whether a question or a finished report, and may leave `state`
|
|
170
|
+
// reading IN_PROGRESS while it waits.
|
|
171
|
+
if (config.settleOnMessage && fresh.some(activity => activity.agentMessaged !== undefined)) {
|
|
172
|
+
trigger = 'message';
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
const elapsed = Date.now() - started;
|
|
176
|
+
if (elapsed >= budgetMs) {
|
|
177
|
+
trigger = 'timeout';
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
// Never oversleep past the deadline: a long poll interval must not turn a
|
|
181
|
+
// short budget into a much longer watch.
|
|
182
|
+
previousState = state;
|
|
183
|
+
await sleep(Math.min(config.pollIntervalMs, Math.max(250, budgetMs - elapsed)), signal);
|
|
184
|
+
current = await client.getSession(session, signal);
|
|
185
|
+
polls += 1;
|
|
186
|
+
let page;
|
|
187
|
+
try {
|
|
188
|
+
page = await client.listActivities(session, {
|
|
189
|
+
signal,
|
|
190
|
+
pageSize: 100,
|
|
191
|
+
...cursorUsable && cursor !== undefined ? { since: cursor } : {},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
// The cursor form is not in the API reference — the documented
|
|
196
|
+
// `?createTime=` parameter is rejected outright — so a 400 here means this
|
|
197
|
+
// deployment does not take the filter either. Drop the cursor rather than
|
|
198
|
+
// the watch: de-duplication already makes correctness independent of it.
|
|
199
|
+
if (!cursorUsable || !(error instanceof JulesError) || error.status !== 400)
|
|
200
|
+
throw error;
|
|
201
|
+
cursorUsable = false;
|
|
202
|
+
page = await client.listActivities(session, { signal, pageSize: 100 });
|
|
203
|
+
}
|
|
204
|
+
fresh = absorb(collected, seen, page.activities ?? []);
|
|
205
|
+
cursor = newestTime(collected) ?? cursor;
|
|
206
|
+
}
|
|
207
|
+
const detail = sessionDetail(current, collected);
|
|
208
|
+
const waitedMs = Date.now() - started;
|
|
209
|
+
const wait = {
|
|
210
|
+
id: detail.id,
|
|
211
|
+
title: detail.title,
|
|
212
|
+
state: detail.state,
|
|
213
|
+
url: detail.url,
|
|
214
|
+
settled: settledState(detail.state),
|
|
215
|
+
timedOut: trigger === 'timeout',
|
|
216
|
+
needsAttention: needsAttentionState(detail.state),
|
|
217
|
+
waitedMs,
|
|
218
|
+
polls,
|
|
219
|
+
planId: detail.planId,
|
|
220
|
+
planSteps: detail.planSteps,
|
|
221
|
+
pullRequests: detail.pullRequests,
|
|
222
|
+
lastMessage: detail.lastMessage,
|
|
223
|
+
lastProgress: detail.lastProgress,
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
trigger,
|
|
227
|
+
detail: describeWatch(trigger, detail.state, waitedMs),
|
|
228
|
+
report: renderWait(wait),
|
|
229
|
+
wait,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* One short phrase describing why a watch stopped, for the job status line.
|
|
234
|
+
* @param trigger - why the watch stopped.
|
|
235
|
+
* @param state - the session state at settle time.
|
|
236
|
+
* @param waitedMs - how long the watch ran.
|
|
237
|
+
* @returns the status detail.
|
|
238
|
+
*/
|
|
239
|
+
function describeWatch(trigger, state, waitedMs) {
|
|
240
|
+
const seconds = Math.round(waitedMs / 1000);
|
|
241
|
+
if (trigger === 'timeout')
|
|
242
|
+
return `still ${state} after ${seconds}s`;
|
|
243
|
+
if (trigger === 'failed')
|
|
244
|
+
return `session failed (${state})`;
|
|
245
|
+
if (trigger === 'completed')
|
|
246
|
+
return `session completed (${state})`;
|
|
247
|
+
// "checkpoint" not "finished": an agent message means the floor is the
|
|
248
|
+
// caller's, and the session may well keep going once they answer.
|
|
249
|
+
if (trigger === 'message')
|
|
250
|
+
return `checkpoint: agent message (${state})`;
|
|
251
|
+
return `session ${state}`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Register `jules_watch`.
|
|
255
|
+
*
|
|
256
|
+
* Registered through `ctx.inject(['jobs'])` rather than a plugin-level
|
|
257
|
+
* injection, so a composition without the job registry still gets the rest of
|
|
258
|
+
* the tool family instead of holding the whole plugin pending forever.
|
|
259
|
+
* @param ctx - plugin context supplying the job registry.
|
|
260
|
+
* @param client - the configured Jules client.
|
|
261
|
+
* @param config - watch budgets and polling bounds.
|
|
262
|
+
* @param journal - durable record of armed watches, when storage is mounted.
|
|
263
|
+
*/
|
|
264
|
+
export function registerJulesWatch(ctx, client, config, journal, live) {
|
|
265
|
+
ctx.tools.register(defineTool({
|
|
266
|
+
name: 'jules_watch',
|
|
267
|
+
description: 'Watch a Jules session in the background — this is how you wait for one. Returns a job id immediately and notifies '
|
|
268
|
+
+ 'you when the session completes, fails, needs a plan decision, or the agent posts a message; read the report with job_output. '
|
|
269
|
+
+ 'Call it once, then END YOUR TURN and do other work. Do not poll jules_status alongside it: the notice is the signal, and nothing '
|
|
270
|
+
+ 'you check in the meantime will have changed.',
|
|
271
|
+
parameters: {
|
|
272
|
+
session: { type: 'string', required: true, description: 'Session id, "sessions/<id>", or the session URL.' },
|
|
273
|
+
timeoutMs: {
|
|
274
|
+
type: 'number',
|
|
275
|
+
description: 'How long to keep watching before giving up, in milliseconds. '
|
|
276
|
+
+ 'Defaults to the configured watchDefaultMs and is capped by watchMaxMs.',
|
|
277
|
+
},
|
|
278
|
+
until: {
|
|
279
|
+
type: 'array',
|
|
280
|
+
description: 'Session states that end the watch. Defaults to completion, failure, a pending plan, and a question.',
|
|
281
|
+
items: { type: 'string' },
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
output: {
|
|
285
|
+
schema: {
|
|
286
|
+
type: 'object', additionalProperties: false,
|
|
287
|
+
properties: {
|
|
288
|
+
kind: { type: 'string', required: true, const: 'background' },
|
|
289
|
+
jobId: { type: 'string', required: true },
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
render: (args, value) => [{
|
|
293
|
+
type: 'text',
|
|
294
|
+
text: `Watching Jules session ${args.session} in the background as job ${value.jobId}. `
|
|
295
|
+
+ 'You will be notified when it finishes or needs you; read the report with job_output. '
|
|
296
|
+
+ 'Do not poll it; continue with other work.',
|
|
297
|
+
}],
|
|
298
|
+
},
|
|
299
|
+
// Read-only against the service: it registers local work and asks Jules for
|
|
300
|
+
// nothing new, so watching several sessions in one turn may overlap.
|
|
301
|
+
isConcurrencySafe: () => true,
|
|
302
|
+
async execute(args, exec) {
|
|
303
|
+
const owner = exec.agent;
|
|
304
|
+
if (owner === undefined) {
|
|
305
|
+
throw new Error('jules_watch requires an agent Session, because the completion notice is delivered to the session that started the watch');
|
|
306
|
+
}
|
|
307
|
+
const session = sessionIdOf(args.session);
|
|
308
|
+
const requested = args.timeoutMs ?? config.defaultMs;
|
|
309
|
+
// Floored at 30s: a watch shorter than the poll interval cannot observe
|
|
310
|
+
// anything, so a caller asking for one would pay for a job that reports
|
|
311
|
+
// only the state it already had. Ceilinged by config so a caller cannot
|
|
312
|
+
// pin a poller on this machine indefinitely.
|
|
313
|
+
const budget = Math.max(30_000, Math.min(Math.trunc(requested), config.maxMs));
|
|
314
|
+
const targets = new Set(args.until === undefined || args.until.length === 0 ? DEFAULT_WAIT_STATES : args.until);
|
|
315
|
+
const jobId = startWatchJob(ctx, client, owner, session, targets, budget, config, journal, live);
|
|
316
|
+
return { kind: 'background', jobId: String(jobId) };
|
|
317
|
+
},
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Start the polling job and return its id.
|
|
322
|
+
*
|
|
323
|
+
* The controller is task-owned rather than the tool call's: once the job id is
|
|
324
|
+
* published the model is tracking that work, so a later cancellation of the
|
|
325
|
+
* originating call must not silently kill it. `job_kill`, owner disposal,
|
|
326
|
+
* and service teardown own this lifetime instead.
|
|
327
|
+
* @param ctx - plugin context supplying the job registry.
|
|
328
|
+
* @param client - the configured Jules client.
|
|
329
|
+
* @param owner - the agent that receives the completion notice.
|
|
330
|
+
* @param session - bare session id.
|
|
331
|
+
* @param targets - states that end the watch.
|
|
332
|
+
* @param budgetMs - total watch budget.
|
|
333
|
+
* @param config - polling bounds.
|
|
334
|
+
* @returns the registry-issued job id.
|
|
335
|
+
*/
|
|
336
|
+
function startWatchJob(ctx, client, owner, session, targets, budgetMs, config, journal, live) {
|
|
337
|
+
const controller = new AbortController();
|
|
338
|
+
let cancelled = false;
|
|
339
|
+
let settle = () => { };
|
|
340
|
+
const done = new Promise((resolve) => { settle = resolve; });
|
|
341
|
+
const watch = async () => {
|
|
342
|
+
try {
|
|
343
|
+
const outcome = await runWatch(client, session, targets, budgetMs, config, controller.signal);
|
|
344
|
+
// The watch itself succeeded. A failed or waiting *session* is reported in
|
|
345
|
+
// the detail and the report, not as a broken job.
|
|
346
|
+
settle({ status: 'completed', detail: outcome.detail, output: outcome.report });
|
|
347
|
+
// It reached its own conclusion, so the journal has nothing left to
|
|
348
|
+
// report. A kill or a teardown deliberately does NOT release: a teardown
|
|
349
|
+
// is precisely the restart this journal exists to survive.
|
|
350
|
+
live.delete(session);
|
|
351
|
+
void journal()?.release(session);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (cancelled) {
|
|
355
|
+
settle({ status: 'killed', detail: 'watch cancelled' });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
settle({ status: 'failed', detail: error instanceof Error ? error.message : String(error) });
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const jobId = ctx.jobs.start({
|
|
362
|
+
kind: 'jules',
|
|
363
|
+
label: `session ${session}`,
|
|
364
|
+
owner,
|
|
365
|
+
run: () => {
|
|
366
|
+
void watch();
|
|
367
|
+
return {
|
|
368
|
+
cancel: (reason) => {
|
|
369
|
+
if (cancelled)
|
|
370
|
+
return;
|
|
371
|
+
cancelled = true;
|
|
372
|
+
controller.abort(new Error(reason ?? 'watch cancelled'));
|
|
373
|
+
},
|
|
374
|
+
done,
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
// Recorded only once the registry has accepted the job, so a refusal at the
|
|
379
|
+
// concurrency cap leaves no phantom entry behind.
|
|
380
|
+
const opened = journal();
|
|
381
|
+
if (opened !== undefined) {
|
|
382
|
+
const armedAt = Date.now();
|
|
383
|
+
live.add(session);
|
|
384
|
+
void opened.arm({ session, startedAt: armedAt, expiresAt: armedAt + budgetMs });
|
|
385
|
+
}
|
|
386
|
+
return jobId;
|
|
387
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-jules",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Google Jules remote coding agent integration for DeepSeek Harness",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"jules",
|
|
10
|
+
"google-jules",
|
|
11
|
+
"coding-agent",
|
|
12
|
+
"remote-agent"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Aurimas Šeputis"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/rbviz/dsh-plugin-jules.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/rbviz/dsh-plugin-jules#readme",
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/rbviz/dsh-plugin-jules/issues"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "lib/index.js",
|
|
31
|
+
"types": "lib/types/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./lib/types/index.d.ts",
|
|
35
|
+
"default": "./lib/index.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"lib",
|
|
40
|
+
"cordis.patch.yml",
|
|
41
|
+
"scripts",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
52
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
|
53
|
+
"@deepseek-ai/dsh-credentials": "^0.1.5-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.5-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
|
|
59
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
60
|
+
"zod": "^4.4.3"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
64
|
+
"@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
|
|
65
|
+
"@deepseek-ai/dsh-credentials": "^0.1.5-rc.2",
|
|
66
|
+
"@deepseek-ai/dsh-jobs": "^0.1.5-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-launch-environment": "^0.1.5-rc.2",
|
|
68
|
+
"@deepseek-ai/dsh-storage-domain": "^0.1.5-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.2",
|
|
70
|
+
"@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
|
|
71
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
72
|
+
"zod": "^4.4.3",
|
|
73
|
+
"@deepseek-ai/cordis-plugin-include": "^1.0.7",
|
|
74
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.3",
|
|
75
|
+
"@deepseek-ai/dsh-jobs-local": "^0.1.5-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-storage": "^0.1.5-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-storage-json": "^0.1.5-rc.2",
|
|
78
|
+
"typescript": "^6.0.3"
|
|
79
|
+
},
|
|
80
|
+
"scripts": {
|
|
81
|
+
"build": "node scripts/build.mjs",
|
|
82
|
+
"typecheck": "node scripts/build.mjs --noEmit",
|
|
83
|
+
"link-dsh-deps": "node scripts/link-dsh-deps.mjs",
|
|
84
|
+
"test": "node scripts/build.mjs && node --test test/*.test.mjs"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Compile the plugin with the TypeScript that the running dsh installation
|
|
4
|
+
* already ships, so this package needs no toolchain of its own.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order: `$DSH_TSC`, then a `typescript` found through this
|
|
7
|
+
* package's own `node_modules` (populated by `link-dsh-deps`), then the
|
|
8
|
+
* harness home. Run `npm run link-dsh-deps` first if this fails.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from 'node:child_process'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
|
+
|
|
18
|
+
function candidateTsc() {
|
|
19
|
+
if (process.env.DSH_TSC !== undefined && existsSync(process.env.DSH_TSC)) return process.env.DSH_TSC
|
|
20
|
+
const home = process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh')
|
|
21
|
+
return [
|
|
22
|
+
join(PACKAGE_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
23
|
+
join(home, 'profiles', 'node_modules', 'typescript', 'bin', 'tsc'),
|
|
24
|
+
].find(existsSync)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tsc = candidateTsc()
|
|
28
|
+
if (tsc === undefined) {
|
|
29
|
+
process.stderr.write('build: no typescript found; run "npm run link-dsh-deps" or set DSH_TSC\n')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = spawnSync(process.execPath, [tsc, '-p', join(PACKAGE_ROOT, 'tsconfig.json'), ...process.argv.slice(2)], {
|
|
34
|
+
stdio: 'inherit',
|
|
35
|
+
cwd: PACKAGE_ROOT,
|
|
36
|
+
})
|
|
37
|
+
process.exit(result.status ?? 1)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Make the in-box `@deepseek-ai/*` packages importable from this plugin's own
|
|
4
|
+
* directory.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: a profile installs an out-of-tree bundle with pnpm's
|
|
7
|
+
* `link:` protocol, which puts a symlink in the profile's `node_modules` but
|
|
8
|
+
* creates no `node_modules` beside the real package. Node resolves a symlinked
|
|
9
|
+
* package to its realpath, so this plugin's own `import '@deepseek-ai/dsh-tools'`
|
|
10
|
+
* would otherwise fail with ERR_MODULE_NOT_FOUND even though dsh itself loaded
|
|
11
|
+
* the file.
|
|
12
|
+
*
|
|
13
|
+
* The fix is to point each declared peer dependency at the copy the running
|
|
14
|
+
* installation already uses, so both sides share one module instance. dsh
|
|
15
|
+
* maintains exactly such a directory at `$DSH_HOME/profiles/node_modules`
|
|
16
|
+
* (its "module fallback"), whose entries are themselves links into the
|
|
17
|
+
* installation.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node scripts/link-dsh-deps.mjs [--home <dsh home>] [--check]
|
|
21
|
+
*
|
|
22
|
+
* `--check` reports what is missing without writing anything.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { dirname, join, resolve } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
|
|
31
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
32
|
+
const MANIFEST = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'))
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every name to link: runtime peers plus the loader packages the composition
|
|
36
|
+
* test boots. Types alone still need resolution, so this is not filtered.
|
|
37
|
+
*/
|
|
38
|
+
const REQUIRED = [...new Set([
|
|
39
|
+
...Object.keys(MANIFEST.peerDependencies ?? {}),
|
|
40
|
+
...Object.keys(MANIFEST.devDependencies ?? {}),
|
|
41
|
+
])]
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const options = { home: process.env.DSH_HOME, check: false, optional: false }
|
|
45
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
46
|
+
const arg = argv[index]
|
|
47
|
+
if (arg === '--check') options.check = true
|
|
48
|
+
else if (arg === '--optional') options.optional = true
|
|
49
|
+
else if (arg === '--home') options.home = argv[++index]
|
|
50
|
+
else if (arg.startsWith('--home=')) options.home = arg.slice('--home='.length)
|
|
51
|
+
else {
|
|
52
|
+
process.stderr.write(`link-dsh-deps: unknown argument ${arg}\n`)
|
|
53
|
+
process.exit(2)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (options.home === undefined || options.home.length === 0) options.home = join(homedir(), '.dsh')
|
|
57
|
+
return { home: resolve(options.home), check: options.check, optional: options.optional }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function homedir() {
|
|
61
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `node_modules` directories that may hold the in-box packages, best candidate first. */
|
|
65
|
+
function candidateRoots(home) {
|
|
66
|
+
const roots = [join(home, 'profiles', 'node_modules')]
|
|
67
|
+
const profiles = join(home, 'profiles')
|
|
68
|
+
if (existsSync(profiles)) {
|
|
69
|
+
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
70
|
+
if (!entry.isDirectory() || entry.name === 'node_modules') continue
|
|
71
|
+
roots.push(join(profiles, entry.name, 'node_modules'))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return roots
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** First existing directory providing `packageName`, or undefined. */
|
|
78
|
+
function locate(packageName, roots) {
|
|
79
|
+
for (const root of roots) {
|
|
80
|
+
const candidate = join(root, packageName)
|
|
81
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { home, check, optional } = parseArgs(process.argv.slice(2))
|
|
87
|
+
const roots = candidateRoots(home)
|
|
88
|
+
const target = join(PACKAGE_ROOT, 'node_modules')
|
|
89
|
+
const missing = []
|
|
90
|
+
|
|
91
|
+
for (const packageName of REQUIRED) {
|
|
92
|
+
const source = locate(packageName, roots)
|
|
93
|
+
if (source === undefined) {
|
|
94
|
+
missing.push(packageName)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
const link = join(target, packageName)
|
|
98
|
+
const current = existsSync(link)
|
|
99
|
+
? (() => { try { return readlinkSync(link) } catch { return undefined } })()
|
|
100
|
+
: undefined
|
|
101
|
+
if (current === resolve(source)) continue
|
|
102
|
+
if (check) {
|
|
103
|
+
process.stdout.write(`would link ${packageName} -> ${source}\n`)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
mkdirSync(dirname(link), { recursive: true })
|
|
107
|
+
// rmSync, not unlinkSync: these peers are devDependencies too, so npm may have
|
|
108
|
+
// installed them as real directories. unlinkSync on one throws EISDIR, and
|
|
109
|
+
// swallowing that left the symlink to fail with EEXIST against a directory
|
|
110
|
+
// that was still there — which is exactly how this broke the first time a
|
|
111
|
+
// checkout had both an npm install and a local harness.
|
|
112
|
+
rmSync(link, { recursive: true, force: true })
|
|
113
|
+
symlinkSync(resolve(source), link, 'junction')
|
|
114
|
+
process.stdout.write(`linked ${packageName} -> ${source}\n`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (missing.length > 0) {
|
|
118
|
+
const detail = `link-dsh-deps: could not find ${missing.join(', ')}\n`
|
|
119
|
+
+ ` searched:\n${roots.map(root => ` ${root}\n`).join('')}`
|
|
120
|
+
+ ' pass --home <dsh home> or set DSH_HOME to the harness home that installed them.\n'
|
|
121
|
+
// --optional is for a machine with no harness installed: CI, or a consumer
|
|
122
|
+
// building from a tarball. The packages then come from node_modules like any
|
|
123
|
+
// other dependency, so the absence of a harness home is not a failure.
|
|
124
|
+
if (optional) {
|
|
125
|
+
process.stderr.write(detail + ' (--optional: continuing; node_modules will be used as-is)\n')
|
|
126
|
+
process.exit(0)
|
|
127
|
+
}
|
|
128
|
+
process.stderr.write(detail)
|
|
129
|
+
process.exit(1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (check) process.stdout.write('link-dsh-deps: all peer dependencies resolve\n')
|