@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 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).
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Loop",
4
+ "description": "Repeat a command across turns with /loop; /loop stop ends it."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "循环",
4
+ "description": "用 /loop 跨回合重复一条命令;/loop stop 结束。"
5
+ }
6
+ }
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
+ }