@maci0/dsh-loop 0.10.1
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 +116 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +4 -0
- package/index.js +420 -0
- package/lib/client.js +140 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcel W. Wysocki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# dsh-loop
|
|
2
|
+
|
|
3
|
+
Repeat a command across agent turns in DeepSeek Harness. `/loop` queues a
|
|
4
|
+
command as the agent's next turn and queues it again after each completed
|
|
5
|
+
turn, for a fixed number of rounds or until you stop it.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/loop 10 /perf-review # run /perf-review for 10 rounds
|
|
11
|
+
/loop 10 /perf-review && /cordis-review # run both commands every round
|
|
12
|
+
/loop 10 continue # send "continue" for 10 rounds
|
|
13
|
+
/loop 0 continue # loop forever (0 = infinite)
|
|
14
|
+
/loop pause # hold the round; resume picks it back up
|
|
15
|
+
/loop resume # continue a paused loop
|
|
16
|
+
/loop stop # end the running loop
|
|
17
|
+
/loop status # what is running, and how far along
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Files attached to a `/loop <rounds> <command>` go out with round 1. `pause`,
|
|
21
|
+
`resume`, `stop`, and `status` take no attachments: they answer an error and
|
|
22
|
+
the composer keeps the files.
|
|
23
|
+
|
|
24
|
+
The command can span multiple lines; each round preserves its formatting.
|
|
25
|
+
|
|
26
|
+
In the Web client, a pill above the composer shows the running loop with
|
|
27
|
+
Pause, Resume, and Stop buttons.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
> **Install it as a bundle.** `dsh plugin add …` mounts the row from the
|
|
32
|
+
> package's own patch layer, which is what the settings editor can write to. A
|
|
33
|
+
> row added with `--patch` is an overlay: it disappears at the next start.
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
dsh plugin --profile web add @maci0/dsh-loop@0.10.1
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
|
|
40
|
+
The version is pinned. To upgrade, run the same command with a newer version,
|
|
41
|
+
then restart `dsh web` (bundle layers compose at boot).
|
|
42
|
+
|
|
43
|
+
The bundled `cordis.patch.yml` inserts the `loop` row automatically.
|
|
44
|
+
|
|
45
|
+
## How it works
|
|
46
|
+
|
|
47
|
+
`/loop <rounds> <command>` queues the command as the agent's next turn (round
|
|
48
|
+
1). When the turn that round opened completes, the plugin queues the next
|
|
49
|
+
round until the budget is spent. `rounds = 0` never spends; `/loop stop`
|
|
50
|
+
cancels at any time, and `/loop status` answers whether one is running or
|
|
51
|
+
paused, and at which round. The pill shows the same state, but only the Web
|
|
52
|
+
client has a pill. Loops are per-session.
|
|
53
|
+
|
|
54
|
+
Only the turn a round's own queued message opened moves the loop. A turn
|
|
55
|
+
already running when `/loop` was typed, or one you open between rounds,
|
|
56
|
+
leaves the round where it is. A round turn that ends any way other than
|
|
57
|
+
`completed` (aborted, error, blocked, max-tokens, or interrupted by a
|
|
58
|
+
restart) pauses the loop at that round; `/loop resume` runs the same round
|
|
59
|
+
again.
|
|
60
|
+
|
|
61
|
+
A round is queued once the agent reaches quiescence (`Agent.whenIdle()`),
|
|
62
|
+
never from inside the `turn/end` publication: `followup()` appends, a session
|
|
63
|
+
refuses an append that reenters the event it is publishing, and a wake
|
|
64
|
+
delivered before the retiring turn settles opens no turn.
|
|
65
|
+
|
|
66
|
+
### The pill
|
|
67
|
+
|
|
68
|
+
While a loop is active or paused, a pill (`⟳ command run/rounds` + Pause /
|
|
69
|
+
Resume + Stop) docks in the same `conversation.input.dock` strip as the goal
|
|
70
|
+
bar, ordered right beside it. State path: a `sessionProjections` unit (key
|
|
71
|
+
`loop`) folds events the harness already understands (the plugin's own
|
|
72
|
+
`command/run` and `command/done` rows, and the `user/message` relay lines the
|
|
73
|
+
driver queues each round), and the client half reads the projected view via
|
|
74
|
+
`useProjection('loop')`: push-based, no polling, correct across reloads. A
|
|
75
|
+
verb moves the pill only once its `command/done` settles as `success`. The
|
|
76
|
+
driver appends no custom event type: an unknown non-ignorable type makes the
|
|
77
|
+
persistence read path refuse the whole session. The buttons submit the
|
|
78
|
+
host-side `/loop pause | resume | stop` commands (no model turn). A verb that
|
|
79
|
+
fails replaces the label with "Stop failed" (or Pause, Resume), the error
|
|
80
|
+
message as its tooltip, until the next action or projection change.
|
|
81
|
+
|
|
82
|
+
### Restarts
|
|
83
|
+
|
|
84
|
+
The round driver is process memory; the folded log is durable. After a
|
|
85
|
+
restart the verbs re-adopt an `active` or `paused` fold from the projection
|
|
86
|
+
registry, with the round turn in flight and the round a pause or interruption
|
|
87
|
+
holds, so `/loop stop` clears a pill the fresh process never started, the pill
|
|
88
|
+
can never strand, and `/loop resume` runs the held round. A round turn cut off
|
|
89
|
+
by the restart is closed as `interrupted` in the log, which pauses the loop. A
|
|
90
|
+
stopped or spent fold stays dead and is never re-adopted.
|
|
91
|
+
Queued relays carry the original loop invocation id, so a relay from a stopped
|
|
92
|
+
or replaced loop cannot reactivate its pill or overwrite the newer loop.
|
|
93
|
+
|
|
94
|
+
## Limits
|
|
95
|
+
|
|
96
|
+
- A command that names a skill (`/perf-review`) is queued as a user message
|
|
97
|
+
containing that command; the agent invokes the skill on its turn.
|
|
98
|
+
- No round cap on infinite loops: it runs until `/loop stop`. Watch quota.
|
|
99
|
+
- A finite budget is a whole number up to 9007199254740991; larger values are
|
|
100
|
+
rejected.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on
|
|
105
|
+
bun.
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
bun install --frozen-lockfile
|
|
109
|
+
bun test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
|
|
113
|
+
|
|
114
|
+
## Licence
|
|
115
|
+
|
|
116
|
+
MIT, see [LICENSE](LICENSE).
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Bundle layer: applied when a profile lists this bundle in dsh.profile.bundles
|
|
2
|
+
# (dsh plugin add does that from dsh.bundle). That list is frozen at boot.
|
|
3
|
+
# Do not also paste this row into the profile's cordis.patch.yml: insert does
|
|
4
|
+
# not dedupe ids, two rows would register the plugin twice.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: loop
|
|
7
|
+
name: '@maci0/dsh-loop'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<path d="M18 8.5a9.5 9.5 0 1 1-6.8 2.8" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round"/>
|
|
3
|
+
<path d="M11.2 6.2v6.2h6.2" stroke="#145AF3" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-loop: repeat a command on a cadence of agent turns.
|
|
3
|
+
*
|
|
4
|
+
* `/loop <rounds> <command>` queues the command as the agent's next turn and,
|
|
5
|
+
* after each completed turn, queues the next round until the budget is spent.
|
|
6
|
+
* rounds = 0 means loop forever; `/loop stop` ends the loop early.
|
|
7
|
+
*
|
|
8
|
+
* Examples:
|
|
9
|
+
* /loop 10 /perf-review
|
|
10
|
+
* /loop 10 /perf-review && /cordis-review
|
|
11
|
+
* /loop 10 continue
|
|
12
|
+
* /loop 0 continue
|
|
13
|
+
* /loop pause
|
|
14
|
+
* /loop resume
|
|
15
|
+
* /loop stop
|
|
16
|
+
*
|
|
17
|
+
* Install with `dsh plugin --profile <name> add @maci0/dsh-loop@<version>`.
|
|
18
|
+
* For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod'
|
|
21
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
|
22
|
+
|
|
23
|
+
export const name = 'loop'
|
|
24
|
+
// `agents` resolves the session's agent on turn/end (round driver); the
|
|
25
|
+
// projection registry serves the pill's live loop state to the client half.
|
|
26
|
+
export const inject = ['commands', 'agents']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse `/loop` arguments: `<rounds> <command>`, or `pause` / `resume` /
|
|
30
|
+
* `stop` / `status` (`list` is an alias). The command is free text passed
|
|
31
|
+
* verbatim to the agent each round, so slash-command chains such as
|
|
32
|
+
* `/perf-review && /cordis-review` replay as written. There is one deliberate
|
|
33
|
+
* restriction: a nested `/loop stop` would end the loop from inside its own
|
|
34
|
+
* replay and leave the loop with no way to stop, so it is rejected before the
|
|
35
|
+
* loop starts.
|
|
36
|
+
* @param {string} input - raw text after `/loop`.
|
|
37
|
+
* @returns {{ kind: 'pause' } | { kind: 'resume' } | { kind: 'stop' } | { kind: 'status' } |
|
|
38
|
+
* { kind: 'error', text: string } |
|
|
39
|
+
* { kind: 'loop', rounds: number, command: string }}
|
|
40
|
+
* `rounds` is the total round budget; 0 means infinite.
|
|
41
|
+
*/
|
|
42
|
+
export function parseArgs(input) {
|
|
43
|
+
const trimmed = input.trim()
|
|
44
|
+
const verb = trimmed.toLowerCase()
|
|
45
|
+
if (verb === 'stop') return { kind: 'stop' }
|
|
46
|
+
if (verb === 'pause') return { kind: 'pause' }
|
|
47
|
+
if (verb === 'resume') return { kind: 'resume' }
|
|
48
|
+
// A status verb exists because the pill is a Web surface: a session driven
|
|
49
|
+
// from a terminal has no other way to ask whether a loop is running.
|
|
50
|
+
if (verb === 'status' || verb === 'list') return { kind: 'status' }
|
|
51
|
+
const match = /^(\d+)\s+([\s\S]+)$/.exec(trimmed)
|
|
52
|
+
if (!match) {
|
|
53
|
+
return {
|
|
54
|
+
kind: 'error',
|
|
55
|
+
text: 'Usage: /loop <rounds> <command>, for example /loop 10 /perf-review, /loop 10 /perf-review && /cordis-review, /loop 0 continue (0 = forever). Or /loop pause | resume | stop | status.',
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const command = match[2].trim()
|
|
59
|
+
if (/\/loop\s+stop\b/i.test(command)) {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'error',
|
|
62
|
+
text: 'A nested /loop stop would end the loop from inside its own replay. Stop it from the composer instead.',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// The budget rides in the projection's integer schema and the round relay
|
|
66
|
+
// line, so it must stay an exact integer.
|
|
67
|
+
const rounds = Number(match[1])
|
|
68
|
+
if (!Number.isSafeInteger(rounds)) {
|
|
69
|
+
return { kind: 'error', text: `Rounds must be a whole number from 0 to ${Number.MAX_SAFE_INTEGER}.` }
|
|
70
|
+
}
|
|
71
|
+
return { kind: 'loop', rounds, command }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Human label for the budget. */
|
|
75
|
+
export function budgetLabel(rounds) {
|
|
76
|
+
return rounds === 0 ? '∞ (stop with /loop stop)' : String(rounds)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The queued user message for one round. */
|
|
80
|
+
export function roundMessage(command, run, rounds) {
|
|
81
|
+
return `[loop round ${run}/${budgetLabel(rounds)}]\n${command}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Projection: the /loop pill's live state.
|
|
85
|
+
//
|
|
86
|
+
// The pill folds only events the harness already understands (the loop's own
|
|
87
|
+
// `command/run` and `command/done` rows, and the `user/message` relay lines
|
|
88
|
+
// the driver queues each round), so the plugin never appends a custom event
|
|
89
|
+
// type. A custom type would poison the log: the persistence read path refuses
|
|
90
|
+
// any session containing an unknown non-ignorable type, and the envelope
|
|
91
|
+
// marker cannot be attached through `Session.append`. Out-of-repo plugins
|
|
92
|
+
// cannot extend the known-type catalog, so the durable fold reads only
|
|
93
|
+
// canonical history.
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse one round relay line back into its loop fields. Returns `undefined`
|
|
97
|
+
* for any message the driver did not write.
|
|
98
|
+
*/
|
|
99
|
+
export function parseRoundLine(text) {
|
|
100
|
+
const match = /^\[loop round (\d+)\/(.+?)\]\n([\s\S]+)$/.exec(text)
|
|
101
|
+
if (!match) return undefined
|
|
102
|
+
const run = Number(match[1])
|
|
103
|
+
// The infinite budget reads by its leading glyph: the label after it is
|
|
104
|
+
// display text, and logs written by older releases must keep folding.
|
|
105
|
+
const budget = /^∞(?: .*)?$/.test(match[2]) ? 0 : Number(match[2])
|
|
106
|
+
const command = match[3].trim()
|
|
107
|
+
if (!Number.isSafeInteger(run) || run < 1 || !Number.isSafeInteger(budget) || budget < 0 || command === '') return undefined
|
|
108
|
+
return { run, rounds: budget, command }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True for a message the loop driver queued. */
|
|
112
|
+
function isLoopRelay(event) {
|
|
113
|
+
return event.type === 'user/message'
|
|
114
|
+
&& event.data?.source?.kind === 'loop'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The pill's client view: the running or paused loop, or null for none. */
|
|
118
|
+
const loopViewSchema = z.object({
|
|
119
|
+
phase: z.enum(['active', 'paused']),
|
|
120
|
+
command: z.string().min(1),
|
|
121
|
+
rounds: z.number().int().nonnegative(),
|
|
122
|
+
run: z.number().int().positive(),
|
|
123
|
+
}).nullable()
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fold state: the view, the `/loop` invocation whose `command/run` row awaits
|
|
127
|
+
* its `command/done`, whether the turn a round's relay opened is still
|
|
128
|
+
* running (`inRound`), and the round a pause or an interruption holds back
|
|
129
|
+
* (`held`). The executor appends `command/run` before attachment admission
|
|
130
|
+
* and the handler, so a verb applies only once its paired `command/done`
|
|
131
|
+
* settles as `success`. `inRound` and `held` mirror the driver's memory, so a
|
|
132
|
+
* remount or restart adopts them.
|
|
133
|
+
*/
|
|
134
|
+
const loopStateSchema = z.object({
|
|
135
|
+
loop: loopViewSchema,
|
|
136
|
+
loopId: z.string().nullable(),
|
|
137
|
+
pending: z.object({ commandId: z.string(), args: z.string() }).nullable(),
|
|
138
|
+
inRound: z.boolean(),
|
|
139
|
+
held: z.number().int().positive().nullable(),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
/** Apply one settled `/loop` invocation's arguments to the fold. */
|
|
143
|
+
function settleVerb(state, args) {
|
|
144
|
+
const parsed = parseArgs(args)
|
|
145
|
+
const loop = state.loop
|
|
146
|
+
if (parsed.kind === 'loop') {
|
|
147
|
+
// Round 1's relay always lands after this row: the agent appends it only
|
|
148
|
+
// once its turn claims the inbox, past an await.
|
|
149
|
+
return { loop: { phase: 'active', command: parsed.command, rounds: parsed.rounds, run: 1 }, loopId: state.pending.commandId, pending: null, inRound: false, held: null }
|
|
150
|
+
}
|
|
151
|
+
if (parsed.kind === 'stop') return { loop: null, loopId: state.pending.commandId, pending: null, inRound: false, held: null }
|
|
152
|
+
if (loop === null) return { ...state, pending: null }
|
|
153
|
+
if (parsed.kind === 'pause' && loop.phase === 'active') {
|
|
154
|
+
// Paused between rounds, the driver holds the next round once its wait
|
|
155
|
+
// for quiescence ends; a relay that still lands replaces this guess.
|
|
156
|
+
const held = state.inRound ? state.held : loop.run + 1
|
|
157
|
+
return { ...state, loop: { ...loop, phase: 'paused' }, pending: null, held }
|
|
158
|
+
}
|
|
159
|
+
if (parsed.kind === 'resume' && loop.phase === 'paused') {
|
|
160
|
+
return { ...state, loop: { ...loop, phase: 'active' }, pending: null, held: null }
|
|
161
|
+
}
|
|
162
|
+
return { ...state, pending: null }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The projection unit: fold the loop's own settled invocations (start, pause,
|
|
167
|
+
* resume, stop; the definition records input, so `command/run` carries the
|
|
168
|
+
* verb verbatim in `args`), the claimed `user/message` relay lines (round
|
|
169
|
+
* counter advances), and the end of each turn a relay opened into the pill's
|
|
170
|
+
* client view. A turn no relay opened never moves the fold. Registered
|
|
171
|
+
* through `ctx.inject(['sessionProjections'], …)` so headless assemblies
|
|
172
|
+
* without the registry stay unaffected.
|
|
173
|
+
*/
|
|
174
|
+
export const loopProjection = {
|
|
175
|
+
key: 'loop',
|
|
176
|
+
stateSchema: loopStateSchema,
|
|
177
|
+
init: () => ({ loop: null, loopId: null, pending: null, inRound: false, held: null }),
|
|
178
|
+
apply: (state, event) => {
|
|
179
|
+
if (event.type === 'turn/end') {
|
|
180
|
+
if (!state.inRound) return state
|
|
181
|
+
const loop = state.loop
|
|
182
|
+
if (event.data?.reason?.kind !== 'completed') {
|
|
183
|
+
// An interrupted round pauses the loop and holds that same round.
|
|
184
|
+
return { ...state, inRound: false, loop: { ...loop, phase: 'paused' }, held: loop.run }
|
|
185
|
+
}
|
|
186
|
+
// The spent budget is the fold's terminal edge. The driver drops a spent
|
|
187
|
+
// loop with nothing appended (the round relay is the last canonical
|
|
188
|
+
// row), so this turn/end is the only signal that the pill must clear.
|
|
189
|
+
if (loop.rounds !== 0 && loop.run >= loop.rounds) return { ...state, inRound: false, loop: null, loopId: state.loopId ?? '', held: null }
|
|
190
|
+
return { ...state, inRound: false, held: loop.phase === 'paused' ? loop.run + 1 : null }
|
|
191
|
+
}
|
|
192
|
+
if (event.type === 'command/run' && event.data?.name === 'loop'
|
|
193
|
+
&& typeof event.data.commandId === 'string' && typeof event.data.args === 'string') {
|
|
194
|
+
return { ...state, pending: { commandId: event.data.commandId, args: event.data.args } }
|
|
195
|
+
}
|
|
196
|
+
if (event.type === 'command/done') {
|
|
197
|
+
const pending = state.pending
|
|
198
|
+
if (pending === null || event.data?.commandId !== pending.commandId) return state
|
|
199
|
+
return event.data.kind === 'success' ? settleVerb(state, pending.args) : { ...state, pending: null }
|
|
200
|
+
}
|
|
201
|
+
if (!isLoopRelay(event)) return state
|
|
202
|
+
// A relay already in the inbox may land after stop or a replacement start.
|
|
203
|
+
if (state.loop === null && state.loopId !== null) return state
|
|
204
|
+
if (event.data.source.loopId !== undefined && event.data.source.loopId !== state.loopId) return state
|
|
205
|
+
const blocks = Array.isArray(event.data?.content) ? event.data.content : []
|
|
206
|
+
const text = blocks.filter((block) => block?.type === 'text').map((block) => block.text).join('')
|
|
207
|
+
const round = parseRoundLine(text)
|
|
208
|
+
if (!round) return state
|
|
209
|
+
// A relay queued before a pause still runs; the loop stays paused.
|
|
210
|
+
const phase = state.loop?.phase === 'paused' ? 'paused' : 'active'
|
|
211
|
+
return { ...state, inRound: true, held: null, loop: { phase, command: round.command, rounds: round.rounds, run: round.run } }
|
|
212
|
+
},
|
|
213
|
+
wire: {
|
|
214
|
+
viewSchema: loopViewSchema,
|
|
215
|
+
view: state => state.loop,
|
|
216
|
+
},
|
|
217
|
+
stateVersion: 6,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Queue one round as the agent's next turn. The relay's message id is how the
|
|
222
|
+
* driver recognizes the turn that round opened: only that turn's end moves
|
|
223
|
+
* the loop, never a turn the user or another plugin opened.
|
|
224
|
+
* @param {object} agent - the session's agent.
|
|
225
|
+
* @param {object} loop - the driven loop.
|
|
226
|
+
* @param {number} run - the round to queue.
|
|
227
|
+
* @param {readonly object[]} attachments - blocks to send with the round.
|
|
228
|
+
*/
|
|
229
|
+
function queueRound(agent, loop, run, attachments) {
|
|
230
|
+
const message = createUserMessage({
|
|
231
|
+
content: [...attachments, { type: 'text', text: roundMessage(loop.command, run, loop.rounds) }],
|
|
232
|
+
source: { kind: 'loop', form: 'relay', ...(loop.id === undefined ? {} : { loopId: loop.id }) },
|
|
233
|
+
})
|
|
234
|
+
loop.run = run
|
|
235
|
+
loop.relayId = message.id
|
|
236
|
+
loop.turnOpen = false
|
|
237
|
+
agent.followup(message)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read the durable loop view for one session. The round driver's `loops` map
|
|
242
|
+
* is process memory and dies on restart; the session log and the projection
|
|
243
|
+
* unit folding it survive. Without this fallback, a restart leaves the pill
|
|
244
|
+
* showing an active loop the verbs cannot see: `/loop stop` answers "No loop
|
|
245
|
+
* is running" while the pill never clears.
|
|
246
|
+
*/
|
|
247
|
+
function readProjectedLoop(ctx, session) {
|
|
248
|
+
try {
|
|
249
|
+
const projected = ctx.get?.('sessionProjections')?.stateOf?.(session, 'loop')
|
|
250
|
+
return projected?.loop ? projected : undefined
|
|
251
|
+
} catch {
|
|
252
|
+
// A fold that cannot materialize (a gap in the log) leaves the verbs on
|
|
253
|
+
// the live map alone, the same answer as a host without the registry.
|
|
254
|
+
return undefined
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The loop for one session: the live map entry, or the durable view adopted
|
|
260
|
+
* back into the map when the process forgot it (restart, remount), with the
|
|
261
|
+
* round turn in flight and the held round. A null view (stopped or spent)
|
|
262
|
+
* stays dead.
|
|
263
|
+
*/
|
|
264
|
+
function liveLoop(ctx, state, session) {
|
|
265
|
+
const loop = state.loops.get(session.id)
|
|
266
|
+
if (loop) return loop
|
|
267
|
+
const fold = readProjectedLoop(ctx, session)
|
|
268
|
+
if (!fold) return undefined
|
|
269
|
+
const adopted = {
|
|
270
|
+
id: fold.loopId ?? undefined,
|
|
271
|
+
command: fold.loop.command,
|
|
272
|
+
rounds: fold.loop.rounds,
|
|
273
|
+
run: fold.loop.run,
|
|
274
|
+
paused: fold.loop.phase === 'paused',
|
|
275
|
+
held: fold.held ?? undefined,
|
|
276
|
+
turnOpen: fold.inRound === true,
|
|
277
|
+
}
|
|
278
|
+
state.loops.set(session.id, adopted)
|
|
279
|
+
return adopted
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function loopHandler(invocation, state, ctx) {
|
|
283
|
+
const parsed = parseArgs(invocation.rawInput)
|
|
284
|
+
if (parsed.kind === 'error') return { kind: 'error', text: parsed.text }
|
|
285
|
+
|
|
286
|
+
// Only a start sends attachments (with round 1). A verb opens no turn of its
|
|
287
|
+
// own, so it refuses them and the composer keeps the files, as the command
|
|
288
|
+
// contract asks of a handler that cannot use them.
|
|
289
|
+
if (parsed.kind !== 'loop' && invocation.attachments.length > 0) {
|
|
290
|
+
return { kind: 'error', text: `/loop ${parsed.kind} takes no attachments; send them with the command a loop repeats.` }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sessionId = invocation.agent.session.id
|
|
294
|
+
if (parsed.kind === 'stop') {
|
|
295
|
+
const loop = liveLoop(ctx, state, invocation.agent.session)
|
|
296
|
+
if (!loop) return { kind: 'success', text: 'No loop is running.' }
|
|
297
|
+
state.loops.delete(sessionId)
|
|
298
|
+
return { kind: 'success', text: `Loop for "${loop.command}" stopped after ${loop.run} round(s).` }
|
|
299
|
+
}
|
|
300
|
+
if (parsed.kind === 'status') {
|
|
301
|
+
const loop = liveLoop(ctx, state, invocation.agent.session)
|
|
302
|
+
if (!loop) return { kind: 'success', text: 'No loop is running.' }
|
|
303
|
+
const state_ = loop.paused ? 'paused' : 'running'
|
|
304
|
+
return {
|
|
305
|
+
kind: 'success',
|
|
306
|
+
text: `Loop for "${loop.command}" is ${state_} at round ${loop.run} of ${budgetLabel(loop.rounds)}.`,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (parsed.kind === 'pause') {
|
|
310
|
+
const loop = liveLoop(ctx, state, invocation.agent.session)
|
|
311
|
+
if (!loop) return { kind: 'success', text: 'No loop is running.' }
|
|
312
|
+
if (loop.paused) return { kind: 'success', text: `Loop for "${loop.command}" is already paused at round ${loop.run}.` }
|
|
313
|
+
loop.paused = true
|
|
314
|
+
return { kind: 'success', text: `Loop for "${loop.command}" paused at round ${loop.run}.` }
|
|
315
|
+
}
|
|
316
|
+
if (parsed.kind === 'resume') {
|
|
317
|
+
const loop = liveLoop(ctx, state, invocation.agent.session)
|
|
318
|
+
if (!loop) return { kind: 'success', text: 'No loop is running.' }
|
|
319
|
+
if (!loop.paused) return { kind: 'success', text: `Loop for "${loop.command}" is already running.` }
|
|
320
|
+
loop.paused = false
|
|
321
|
+
const text = `Loop for "${loop.command}" resumed at round ${loop.run}.`
|
|
322
|
+
// The round a paused or interrupted turn held back has to be queued here:
|
|
323
|
+
// pause and resume are plugin commands and open no turn, so nothing else
|
|
324
|
+
// would ever drive it.
|
|
325
|
+
const held = loop.held
|
|
326
|
+
loop.held = undefined
|
|
327
|
+
if (held !== undefined) queueRound(invocation.agent, loop, held, [])
|
|
328
|
+
return { kind: 'success', text }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const loop = { id: invocation.commandId, command: parsed.command, rounds: parsed.rounds, run: 1 }
|
|
332
|
+
state.loops.set(sessionId, loop)
|
|
333
|
+
queueRound(invocation.agent, loop, 1, invocation.attachments)
|
|
334
|
+
return {
|
|
335
|
+
kind: 'success',
|
|
336
|
+
text: `Loop started: "${parsed.command}" for ${budgetLabel(parsed.rounds)} round(s).`,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function apply(ctx) {
|
|
341
|
+
// Per-instance driver state: module-level state would outlive an unload and
|
|
342
|
+
// leak across instances.
|
|
343
|
+
const state = { loops: new Map() }
|
|
344
|
+
|
|
345
|
+
// Serve the pill's live loop state when the projection registry is mounted.
|
|
346
|
+
ctx.inject?.(['sessionProjections'], (scope) => {
|
|
347
|
+
scope.sessionProjections.register(loopProjection)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
ctx.effect(() => {
|
|
351
|
+
const disposers = []
|
|
352
|
+
disposers.push(ctx.commands.register({
|
|
353
|
+
definitionId: 'dsh-loop:loop',
|
|
354
|
+
name: 'loop',
|
|
355
|
+
description: '⟳ Repeat a command each turn: /loop <rounds> <command> (0 = forever), /loop pause | resume | stop | status',
|
|
356
|
+
input: { hint: '<rounds> <command> | pause | resume | stop | status', attachments: true },
|
|
357
|
+
handler: (inv) => loopHandler(inv, state, ctx),
|
|
358
|
+
}))
|
|
359
|
+
// After the turn a round opened completes, queue the next round until the
|
|
360
|
+
// budget spends. A turn the round did not open (one already running when
|
|
361
|
+
// /loop was typed, one the user opened between rounds) never moves the
|
|
362
|
+
// loop. A round turn that ends any other way (aborted, error, blocked,
|
|
363
|
+
// max-tokens, interrupted by a restart) pauses the loop and holds that same
|
|
364
|
+
// round for resume. A paused loop holds its next round: the completed turn
|
|
365
|
+
// settles with nothing queued, and resume picks up exactly where it left
|
|
366
|
+
// off. The pill follows the same rows, so the driver appends nothing.
|
|
367
|
+
const offTurn = ctx.on('session/event', (session, event) => {
|
|
368
|
+
const loop = state.loops.get(session.id)
|
|
369
|
+
if (!loop) return
|
|
370
|
+
if (event?.type === 'user/message') {
|
|
371
|
+
if (event.data?.source?.kind === 'loop' && event.data.id === loop.relayId) loop.turnOpen = true
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (event?.type !== 'turn/end' || !loop.turnOpen) return
|
|
375
|
+
loop.turnOpen = false
|
|
376
|
+
loop.relayId = undefined
|
|
377
|
+
if (event.data?.reason?.kind !== 'completed') {
|
|
378
|
+
loop.paused = true
|
|
379
|
+
loop.held = loop.run
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
if (loop.rounds !== 0 && loop.run >= loop.rounds) {
|
|
383
|
+
state.loops.delete(session.id)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
// A paused loop holds its round rather than dropping it; `resume` queues
|
|
387
|
+
// it, because a turn/end is the only other driver and a plugin command
|
|
388
|
+
// starts no turn.
|
|
389
|
+
if (loop.paused) {
|
|
390
|
+
loop.held = loop.run + 1
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
const agent = ctx.agents.get(session.id)
|
|
394
|
+
if (!agent || agent.session !== session) return
|
|
395
|
+
const run = loop.run + 1
|
|
396
|
+
loop.run = run
|
|
397
|
+
// The round waits for quiescence: `followup` appends, and a session
|
|
398
|
+
// refuses an append that reenters the event being published, while a
|
|
399
|
+
// wake delivered before the retiring turn settles never opens a turn.
|
|
400
|
+
// A stop, pause, or restart during the wait re-checks.
|
|
401
|
+
void agent.whenIdle().then(() => {
|
|
402
|
+
if (state.loops.get(session.id) !== loop) return
|
|
403
|
+
if (loop.paused) {
|
|
404
|
+
loop.held = run
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (ctx.agents.get(session.id) !== agent) return
|
|
408
|
+
queueRound(agent, loop, run, [])
|
|
409
|
+
}, () => {})
|
|
410
|
+
})
|
|
411
|
+
return () => {
|
|
412
|
+
offTurn?.()
|
|
413
|
+
for (const d of disposers) d()
|
|
414
|
+
// Drop the driven loops with the plugin: a round still waiting on
|
|
415
|
+
// quiescence must not queue into an unloaded row, and a remount re-adopts
|
|
416
|
+
// any live loop from the durable fold anyway.
|
|
417
|
+
state.loops.clear()
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-loop: browser half.
|
|
3
|
+
*
|
|
4
|
+
* The loop pill in the conversation.input.dock strip (same dock as the goal
|
|
5
|
+
* bar). Live state arrives through `useProjection('loop')`: the host half
|
|
6
|
+
* registers a projection unit folding the loop's own command rows and round
|
|
7
|
+
* relay lines, so the pill needs no polling and survives page reloads. Stop submits the host-side
|
|
8
|
+
* `/loop stop` command (a plugin command, not a model turn).
|
|
9
|
+
*
|
|
10
|
+
* Plain JavaScript on purpose: the client module system serves this file as a
|
|
11
|
+
* lazy-CJS factory on `window.__ModuleLoader__`; `react` is provided.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
window.__ModuleLoader__.load({
|
|
15
|
+
id: '@maci0/dsh-loop',
|
|
16
|
+
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} }
|
|
19
|
+
var exports = module.exports
|
|
20
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
21
|
+
|
|
22
|
+
const React = require('react')
|
|
23
|
+
const createElement = React.createElement
|
|
24
|
+
|
|
25
|
+
// Full-width bar matching the goal bar's dock geometry (GoalBar.module.css:
|
|
26
|
+
// same 36px bar, 12px radius, tip background, centered column). The dock
|
|
27
|
+
// stack is vertical and full-width, so a fit-content chip floats left;
|
|
28
|
+
// the bar fills the column exactly like the goal widget does.
|
|
29
|
+
const CSS = [
|
|
30
|
+
'.loop-dock{box-sizing:border-box;width:calc(100% - var(--dsh-composer-side-clearance) - var(--dsh-composer-side-clearance) - var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset));margin:0 auto}',
|
|
31
|
+
'.loop-pill{box-sizing:border-box;display:flex;align-items:center;gap:10px;width:100%;max-width:calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset));height:36px;margin:0 auto;padding:4px 5px 4px 12px;border:0.5px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-specific-tip)}',
|
|
32
|
+
'.loop-pill-paused{opacity:.75}',
|
|
33
|
+
'.loop-glyph{display:inline-flex;flex:none;color:var(--dsw-alias-label-tertiary)}',
|
|
34
|
+
'.loop-label{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:24px;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
35
|
+
'.loop-rounds{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
|
|
36
|
+
'.loop-btn{flex:none;appearance:none;font:inherit;font-size:12px;padding:2px 10px;cursor:pointer;color:var(--dsw-alias-label-primary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
|
|
37
|
+
'.loop-btn:disabled{cursor:default;opacity:.5}',
|
|
38
|
+
'.loop-error{color:var(--dsw-alias-label-error)}',
|
|
39
|
+
].join('')
|
|
40
|
+
|
|
41
|
+
if (typeof document !== 'undefined') {
|
|
42
|
+
const style = document.createElement('style')
|
|
43
|
+
style.textContent = CSS
|
|
44
|
+
document.head.append(style)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Rounds text: 0 means forever. */
|
|
48
|
+
const roundsText = (rounds) => (rounds === 0 ? '∞' : `${rounds}`)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run a pill verb (`/loop pause | resume | stop`) for one session. A
|
|
52
|
+
* failure never rejects out of the click handler: it lands in the pill as
|
|
53
|
+
* `{ verb, message, loop }`, shown while the projected loop is the one the
|
|
54
|
+
* verb failed on. The next action clears it.
|
|
55
|
+
* @param {Function} run - the injected runner.
|
|
56
|
+
* @param {string} sessionId - the dock's session.
|
|
57
|
+
* @param {string} verb - pause, resume, or stop.
|
|
58
|
+
* @param {object} loop - the projected loop the verb acts on.
|
|
59
|
+
* @param {Function} setPending - the pending flag setter.
|
|
60
|
+
* @param {Function} setFailure - the failure setter.
|
|
61
|
+
*/
|
|
62
|
+
const runVerb = async (run, sessionId, verb, loop, setPending, setFailure) => {
|
|
63
|
+
setPending(verb)
|
|
64
|
+
setFailure(null)
|
|
65
|
+
try {
|
|
66
|
+
await run(sessionId, verb)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
setFailure({ verb, message: error instanceof Error ? error.message : String(error), loop })
|
|
69
|
+
} finally {
|
|
70
|
+
setPending(null)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Button copy for a verb, reused in the failure label. */
|
|
75
|
+
const VERB_LABEL = { pause: 'Pause', resume: 'Resume', stop: 'Stop' }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The pill: visible while a loop is active or paused in this session,
|
|
79
|
+
* mirroring the goal bar's pause/resume/stop verbs.
|
|
80
|
+
* @param {object} props - standard dock props (sessionId, useProjection)
|
|
81
|
+
* plus the injected verb runner.
|
|
82
|
+
*/
|
|
83
|
+
function LoopPill({ sessionId, useProjection, onLoop }) {
|
|
84
|
+
const loop = useProjection('loop')
|
|
85
|
+
const [pending, setPending] = React.useState(null)
|
|
86
|
+
const [failure, setFailure] = React.useState(null)
|
|
87
|
+
|
|
88
|
+
if (loop === undefined || loop === null || (loop.phase !== 'active' && loop.phase !== 'paused')) return null
|
|
89
|
+
const paused = loop.phase === 'paused'
|
|
90
|
+
// A projection change (a new loop reference) retires the failure.
|
|
91
|
+
const failed = failure !== null && failure.loop === loop ? failure : null
|
|
92
|
+
const verb = (name) => () => runVerb(onLoop, sessionId, name, loop, setPending, setFailure)
|
|
93
|
+
|
|
94
|
+
return createElement('div', { className: 'loop-dock' },
|
|
95
|
+
createElement('div', { className: paused ? 'loop-pill loop-pill-paused' : 'loop-pill' },
|
|
96
|
+
createElement('span', { className: 'loop-glyph' }, '⟳'),
|
|
97
|
+
failed === null
|
|
98
|
+
? createElement('span', { className: 'loop-label', title: loop.command }, `${paused ? 'Paused · ' : ''}${loop.command}`)
|
|
99
|
+
: createElement('span', { className: 'loop-label loop-error', title: failed.message }, `${VERB_LABEL[failed.verb]} failed`),
|
|
100
|
+
createElement('span', { className: 'loop-rounds' }, ` ${loop.run}/${roundsText(loop.rounds)}`),
|
|
101
|
+
createElement('button', {
|
|
102
|
+
className: 'loop-btn',
|
|
103
|
+
onClick: verb(paused ? 'resume' : 'pause'),
|
|
104
|
+
disabled: pending !== null,
|
|
105
|
+
}, pending === 'pause' || pending === 'resume' ? '…' : paused ? 'Resume' : 'Pause'),
|
|
106
|
+
createElement('button', {
|
|
107
|
+
className: 'loop-btn',
|
|
108
|
+
onClick: verb('stop'),
|
|
109
|
+
disabled: pending !== null,
|
|
110
|
+
}, pending === 'stop' ? '…' : 'Stop'),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
exports.inject = ['slots', 'sessions']
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Client plugin body: the loop pill dock entry.
|
|
119
|
+
* @param ctx - client root context.
|
|
120
|
+
*/
|
|
121
|
+
function apply(ctx) {
|
|
122
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
123
|
+
name: 'conversation.input.dock',
|
|
124
|
+
id: 'loop',
|
|
125
|
+
order: 11,
|
|
126
|
+
inject: (sessionId) => ({
|
|
127
|
+
onLoop: async (id, verb) => {
|
|
128
|
+
const binding = ctx.sessions.binding(id ?? sessionId)
|
|
129
|
+
if (binding === undefined) throw new Error(`dsh-loop: session "${id ?? sessionId}" is unavailable`)
|
|
130
|
+
const result = await binding.session.command(`/loop ${verb}`)
|
|
131
|
+
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
}, LoopPill))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
exports.apply = apply
|
|
138
|
+
return module.exports
|
|
139
|
+
},
|
|
140
|
+
})
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maci0/dsh-loop",
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public",
|
|
6
|
+
"registry": "https://registry.npmjs.org/"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"description": "/loop <rounds> <command> for DeepSeek Harness: repeat a command across agent turns; 0 loops forever, /loop stop ends it.",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./index.js",
|
|
14
|
+
"./client": {
|
|
15
|
+
"default": "./lib/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
18
|
+
"./locale/*.json": "./locale/*.json",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
},
|
|
25
|
+
"client": {
|
|
26
|
+
"platform": "web",
|
|
27
|
+
"inject": [
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-session",
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
31
|
+
"@deepseek-ai/dsh-api-session-controller"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"compatibility": {
|
|
35
|
+
"dsh": ">=0.2.0-rc.2 <0.3.0"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"icon.svg",
|
|
40
|
+
"locale/*.json",
|
|
41
|
+
"index.js",
|
|
42
|
+
"lib/client.js",
|
|
43
|
+
"cordis.patch.yml",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "bun test",
|
|
49
|
+
"test:node": "node --test tests/*.test.*"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "4.0.5-alpha.1"
|
|
56
|
+
},
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "https://github.com/maci0/dsh-loop.git"
|
|
60
|
+
},
|
|
61
|
+
"icon": "./icon.svg",
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"@deepseek-ai/dsh-llm": "0.2.1-alpha.1",
|
|
64
|
+
"zod": "^4.4.3"
|
|
65
|
+
}
|
|
66
|
+
}
|