@banbolee/dsh-fish-shell 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Library: the persistent `fish` PTY backend for DeepSeek Harness — a
3
+ * `BashTerminalBackend` subclass that spawns `fish --no-config -i`
4
+ * (config-overridable) instead of bash, with a fish prompt that speaks the
5
+ * shared `LocalPtySession` readiness contract (OSC `133;D;<status>` + a
6
+ * `dsh> ` prompt tail), and the same sandbox confinement and mode fence as
7
+ * the official bash backend.
8
+ *
9
+ * The readiness contract was verified against real fish 4.0.0: with
10
+ * `TERM=dumb` (the backend's child environment, same as official bash),
11
+ * fish's reader emits no cursor/redraw sequences after the prompt text, so
12
+ * the sanitizer sees exactly `dsh> ` after the OSC marker and readiness
13
+ * settles promptly. The prompt setup is submitted as ONE line (semicolon
14
+ * function bodies) and builds the ESC/BEL bytes at runtime through fish
15
+ * `printf` escapes (`\x1b`, `\x07`), mirroring the official pwsh prompt's
16
+ * runtime-escape approach — raw ESC bytes in submitted PTY input are
17
+ * unreliable under a line editor.
18
+ *
19
+ * This module is NOT mounted by the bundle patch (there is no `fish-terminal`
20
+ * host row): the `terminals` service is entry-local to the `minimal` preset
21
+ * and invisible at the host/agent plane, so a patch row would stay pending
22
+ * forever in host-plane profiles. Instead the policy's persistent tool
23
+ * (`persistent.js`) constructs one `FishTerminalBackend` itself and drives
24
+ * its sessions directly. `apply` below is kept for direct assembly and
25
+ * tests (a context that already mounts the `terminals` registry).
26
+ *
27
+ * `@deepseek-ai/dsh-terminal-bash` does not export its private
28
+ * `ensureSandboxModeFence`/`spawnArgv`/`startupSession` helpers, so the
29
+ * minimal pieces this backend needs are copied here (marked with their
30
+ * official source) rather than monkey-patching the official module.
31
+ *
32
+ * @module @banbolee/dsh-fish-shell/terminal-fish
33
+ */
34
+
35
+ import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
36
+ import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
37
+
38
+ /** Cordis plugin name (used only for direct assembly/tests; the bundle patch
39
+ * does NOT mount this plugin row — see the module doc). */
40
+ export const name = 'fish-terminal'
41
+ /** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
42
+ export const inject = [
43
+ 'terminals',
44
+ 'sandboxPolicy',
45
+ 'sessionProjections',
46
+ 'subprocess',
47
+ ]
48
+
49
+ /** Exact printable prompt emitted after the private marker (shared with the
50
+ * official bash/pwsh backends' readiness contract). */
51
+ const CONTROLLED_PROMPT = 'dsh> '
52
+ /** Default fish executable (resolved on PATH by the subprocess provider). */
53
+ const DEFAULT_FISH_SHELL = 'fish'
54
+ /** Default fish arguments: interactive, without user config interference.
55
+ * `--no-config` exists since fish 3.3 (the README is POSIX-only anyway) and
56
+ * avoids a user `fish_prompt`/alias shadowing the controlled prompt. */
57
+ const DEFAULT_FISH_ARGS = ['--no-config', '-i']
58
+
59
+ /**
60
+ * Defaults for the shared PTY/readiness surface, mirroring the official
61
+ * `@deepseek-ai/dsh-terminal-bash` Config schema (the official module
62
+ * materializes them through schemastery defaults; this backend has no
63
+ * schemastery dependency, so defaulting is explicit here).
64
+ */
65
+ const DEFAULT_PTY_CONFIG = {
66
+ rows: 40,
67
+ cols: 160,
68
+ scrollbackLines: 1e4,
69
+ scrollbackMaxBytes: 4 * 1024 * 1024,
70
+ maxReadBytes: 256 * 1024,
71
+ pollIntervalMs: 50,
72
+ exactProbeAfterMs: 150,
73
+ idleSilenceMs: 3e3,
74
+ handoffGraceMs: 500,
75
+ timeoutMs: 3e4,
76
+ disposeGraceMs: 3e3,
77
+ }
78
+
79
+ /**
80
+ * The terminal handle surface this backend reads: the cleanup verb a failed
81
+ * session factory must call (the real handle is `SubprocessTerminalHandle`).
82
+ * @typedef {{ terminate(): void | Promise<void> }} TerminalHandleLike
83
+ */
84
+
85
+ /**
86
+ * The fully resolved backend configuration: the public `Config` surface with
87
+ * every default materialized, matching `@deepseek-ai/dsh-terminal-bash`'s
88
+ * `ResolvedConfig` (which that package does not export from its root).
89
+ * @typedef {object} ResolvedBackendConfig
90
+ * @property {string} backendType - backend registry type (`fish` by default).
91
+ * @property {'bash'} shellDialect - the readiness/prompt contract the fish prompt implements.
92
+ * @property {string} shellPath - the fish executable to spawn.
93
+ * @property {string[]} shellArgs - the resolved fish arguments.
94
+ * @property {number} rows - initial terminal rows.
95
+ * @property {number} cols - initial terminal columns.
96
+ * @property {number} scrollbackLines - retained logical lines.
97
+ * @property {number} scrollbackMaxBytes - retained UTF-8 bytes.
98
+ * @property {number} maxReadBytes - one read's byte cap.
99
+ * @property {number} pollIntervalMs - readiness polling interval.
100
+ * @property {number} exactProbeAfterMs - delay before exact syscall probes.
101
+ * @property {number} idleSilenceMs - silence that yields `inferred_idle`.
102
+ * @property {number} handoffGraceMs - extra wait for foreground handoff.
103
+ * @property {number} timeoutMs - readiness deadline for one startup sequence.
104
+ * @property {number} disposeGraceMs - SIGTERM→SIGKILL grace for the PTY session.
105
+ */
106
+
107
+ /**
108
+ * The backend session surface this module drives: `TerminalBackendSession`
109
+ * with the backend's own mutable `motd`. The interface declares that field
110
+ * read-only, while the concrete `LocalPtySession` (and the test doubles) assign
111
+ * the startup banner once readiness settles.
112
+ * @typedef {Omit<import('@deepseek-ai/dsh-terminal').TerminalBackendSession, 'motd'> & { motd: string }} PersistentPtySession
113
+ */
114
+
115
+ /**
116
+ * The `BashTerminalBackend` internals this subclass must reach to override
117
+ * `spawn()`. The upstream declarations mark `ctx`, `config`, `spawnTerminal`,
118
+ * and `createSession` private — and are byte-identical in 0.1.5-rc.1 and
119
+ * 0.1.7-rc.2 — while the JS prototype keeps them as ordinary fields, so the
120
+ * subclass reads them through this one structural view instead of a
121
+ * suppression per access.
122
+ * @typedef {object} BashBackendInternals
123
+ * @property {import('@deepseek-ai/cordis').Context} ctx - the backend's plugin context.
124
+ * @property {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
125
+ * @property {(spec: object) => Promise<TerminalHandleLike>} spawnTerminal - the subprocess terminal primitive (`ctx.subprocess.spawnTerminal` by default).
126
+ * @property {(terminal: unknown, config: unknown) => any} createSession - the session factory (`new LocalPtySession(...)` by default).
127
+ */
128
+
129
+ /**
130
+ * One-line fish prompt setup submitted before readiness: suppresses the
131
+ * greeting and right prompt, and defines a prompt that emits the shared OSC
132
+ * `133;D;<status>` + BEL marker followed by exactly `dsh> `. ESC/BEL are
133
+ * built at runtime via fish `printf` escapes (no raw control bytes in
134
+ * submitted input; verified against fish 4.0.0).
135
+ */
136
+ export const FISH_PROMPT_SETUP = 'function fish_greeting; end; function fish_prompt; printf \'\\x1b]133;D;%s\\x07\' $status; echo -n \'dsh> \'; end; function fish_right_prompt; end;'
137
+
138
+ /**
139
+ * Resolve the effective fish shell specification: an unset or empty
140
+ * `shellPath`/`shellArgs` selects the defaults, a non-empty explicit value
141
+ * wins. Mirrors the official `resolveConfig` (minus dialect selection).
142
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} config - the plugin configuration.
143
+ * @returns {ResolvedBackendConfig} the fully resolved configuration.
144
+ */
145
+ export function resolveConfig(config) {
146
+ return {
147
+ // `config.backendType?.length > 0` compared an optional property with `>`
148
+ // (a coercion, not a narrowing); the explicit guard is the same decision
149
+ // and lets the compiler see a `string` in the true branch.
150
+ backendType: config.backendType !== undefined && config.backendType.length > 0 ? config.backendType : 'fish',
151
+ // Inherited `ResolvedConfig` shape, not a dialect choice: this backend
152
+ // overrides `spawn()` with its own child environment and fish prompt, so
153
+ // the field is inert — `bash` names the readiness/prompt contract the
154
+ // fish prompt implements (`dsh-terminal-bash`'s bash branch).
155
+ shellDialect: /** @type {const} */ ('bash'),
156
+ shellPath: config.shellPath !== undefined && config.shellPath.length > 0 ? config.shellPath : DEFAULT_FISH_SHELL,
157
+ shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 ? config.shellArgs : DEFAULT_FISH_ARGS,
158
+ ...DEFAULT_PTY_CONFIG,
159
+ ...Object.fromEntries(Object.entries(config).filter(([key, value]) => key in DEFAULT_PTY_CONFIG && value !== undefined)),
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Validate the effective configuration (mirrors the official
165
+ * `validateConfig`).
166
+ * @param {ResolvedBackendConfig} config - the fully resolved configuration.
167
+ * @returns {void} throws on a value the backend cannot run with.
168
+ */
169
+ export function validateConfig(config) {
170
+ const resolved = config
171
+ if (resolved.backendType.length === 0) throw new Error('terminal-fish: backendType must be non-empty')
172
+ if (resolved.shellPath.length === 0) throw new Error('terminal-fish: shellPath must be non-empty')
173
+ for (const [field, value] of Object.entries(resolved)) {
174
+ if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
175
+ throw new Error(`terminal-fish: ${field} must be a positive safe integer`)
176
+ }
177
+ }
178
+ if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) throw new Error('terminal-fish: maxReadBytes must not exceed scrollbackMaxBytes')
179
+ if (resolved.handoffGraceMs < resolved.pollIntervalMs) throw new Error('terminal-fish: handoffGraceMs must be at least pollIntervalMs so one readiness poll runs inside the grace window')
180
+ }
181
+
182
+ /**
183
+ * Fish child environment: the official bash `childEnvironment` common set
184
+ * (TERM=dumb so the reader emits no cursor/redraw sequences, PAGER=cat,
185
+ * harness identity facts) minus the bash-only PS1/PROMPT_COMMAND.
186
+ * @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the spawn specification.
187
+ * @returns {Record<string, string>} the child environment.
188
+ */
189
+ export function childEnvironment(spec) {
190
+ return {
191
+ TERM: 'dumb',
192
+ PAGER: 'cat',
193
+ GIT_PAGER: 'cat',
194
+ DSH_SHELL: '1',
195
+ DSH_SESSION_ID: spec.owner.id,
196
+ DSH_PTY_SESSION_ID: spec.sessionId,
197
+ }
198
+ }
199
+
200
+ // Copied from the official `@deepseek-ai/dsh-terminal-bash` (not exported
201
+ // there): a per-owner fence refusing `sandbox/mode` changes while persistent
202
+ // terminal sessions are open or being created.
203
+ //
204
+ // Self-managed adaptation: the official copy asks the terminals registry
205
+ // (`state.pty.hasOwnerActivity(owner)`) whether the owner has activity, but
206
+ // the policy's persistent tool drives sessions WITHOUT a registry. Activity
207
+ // checkers are therefore injectable and AGGREGATED per owner: every
208
+ // `registerPersistentFish` manager that spawned for an owner contributes its
209
+ // own checker (the manager's owner→session tracking), and the fence refuses
210
+ // the mode change while ANY checker reports activity. Aggregation matters
211
+ // because managers can churn (persistent→one-shot→persistent): a single-slot
212
+ // overwrite would silently forget the first manager's still-open sessions.
213
+ // A call without a checker (the backend's own fence probe on every spawn)
214
+ // adds nothing, so it cannot clobber policy checkers; when no checker was
215
+ // ever contributed the fence falls back to the terminals registry probe
216
+ // (`ctx.terminals` may be absent — missing services resolve to `undefined`
217
+ // on a Cordis context, never a throw).
218
+ /** Per-owner fence state: the context whose dispatch is watched plus every
219
+ * manager's aggregated activity probe. @type {WeakMap<import('@deepseek-ai/dsh-agent').Agent, { ctx: import('@deepseek-ai/cordis').Context, checkers: Set<() => boolean> }>} */
220
+ const sandboxModeFences = new WeakMap()
221
+
222
+ /**
223
+ * Install (or refresh) the per-owner sandbox-mode fence and register this
224
+ * manager's owner-activity probe with it.
225
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the context whose `internal/dispatch` the fence listens to.
226
+ * @param {import('@deepseek-ai/dsh-agent').Agent} owner - the live owner whose activity keeps the fence closed.
227
+ * @param {() => boolean} [hasActivity] - this manager's owner-activity probe, when it has one.
228
+ */
229
+ export function ensureSandboxModeFence(ctx, owner, hasActivity) {
230
+ let state = sandboxModeFences.get(owner)
231
+ if (state === undefined) {
232
+ state = { ctx, checkers: new Set() }
233
+ sandboxModeFences.set(owner, state)
234
+ owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
235
+ if (eventName !== 'session/event') return
236
+ const [session, event] = args
237
+ if (session !== owner.session || event.type !== 'sandbox/mode') return
238
+ const current = sandboxModeFences.get(owner)
239
+ if (current === undefined) return
240
+ const currentMode = current.ctx.get('sessionProjections')?.stateOf(session, 'sandboxMode') ?? current.ctx.get('sandboxPolicy')?.defaultMode
241
+ if (event.data.mode === currentMode) return
242
+ const active = current.checkers.size > 0
243
+ ? [...current.checkers].some((check) => check())
244
+ : current.ctx.get('terminals')?.hasOwnerActivity(owner) ?? false
245
+ if (active) throw new Error(`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`)
246
+ }, { global: true })
247
+ } else {
248
+ state.ctx = ctx
249
+ }
250
+ if (hasActivity !== undefined) state.checkers.add(hasActivity)
251
+ }
252
+
253
+ /**
254
+ * Confine the fish argv through the shared sandbox policy, exactly like the
255
+ * official `spawnArgv` (which is private to `@deepseek-ai/dsh-terminal-bash`
256
+ * and therefore copied here). `SandboxProvider.confine` is ASYNC in
257
+ * 0.1.7-rc.2, so the confined branch MUST be awaited: reading `.argv` off the
258
+ * returned promise yields `undefined`, which made every confined persistent
259
+ * spawn fail (previously masked by a synchronous test double).
260
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the plugin context carrying the sandbox provider.
261
+ * @param {ResolvedBackendConfig} config - the resolved backend configuration the base class stores.
262
+ * @param {import('@deepseek-ai/dsh-sandbox').SandboxExecutionPolicy} policy - the resolved execution policy for this session; the `danger-full-access` early return narrows `mode` to a confined mode for `confine()`.
263
+ * @param {AbortSignal} [signal] - cancellation of confinement preparation.
264
+ * @returns {Promise<string[]>} the (possibly confined) argv.
265
+ */
266
+ export async function spawnArgv(ctx, config, policy, signal) {
267
+ const argv = [config.shellPath, ...config.shellArgs]
268
+ if (policy.mode === 'danger-full-access') return argv
269
+ const sandbox = ctx.get('sandbox')
270
+ if (sandbox === undefined) throw new Error(`terminal-fish: sandbox mode "${policy.mode}" requires a ctx.sandbox provider in the execution world`)
271
+ return (await sandbox.confine(argv, {
272
+ ...policy,
273
+ mode: policy.mode,
274
+ }, signal)).argv
275
+ }
276
+
277
+ /**
278
+ * Bring the fish session to readiness: submit the prompt setup once, then
279
+ * poll with empty sends until the fish prompt satisfies the shared
280
+ * readiness contract (OSC `133;D;` + `dsh> ` tail). Mirrors the official
281
+ * `startupSession` pwsh branch, with a startup deadline race.
282
+ * @param {PersistentPtySession} session - the backend session to bring up.
283
+ * @param {number} timeoutMs - readiness deadline.
284
+ * @param {AbortSignal} [signal] - optional cancellation.
285
+ */
286
+ export async function startupSession(session, timeoutMs, signal) {
287
+ /** @type {import('@deepseek-ai/dsh-terminal').TerminalSendOperation | undefined} */
288
+ let startupOperation
289
+ const start = async () => {
290
+ let viewport = ''
291
+ for (;;) {
292
+ const first = viewport.length === 0
293
+ startupOperation = session.startSend({
294
+ text: first ? FISH_PROMPT_SETUP : '',
295
+ submit: first,
296
+ ...(signal !== undefined ? { signal } : {}),
297
+ })
298
+ const result = await startupOperation.done
299
+ if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
300
+ if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
301
+ viewport = result.viewport
302
+ if (result.waitReason === 'stdin_read') break
303
+ }
304
+ session.motd = viewport
305
+ }
306
+ const races = []
307
+ let onAbort
308
+ if (signal !== undefined) {
309
+ const aborted = Promise.withResolvers()
310
+ onAbort = () => {
311
+ aborted.reject(signal.reason)
312
+ }
313
+ signal.addEventListener('abort', onAbort, { once: true })
314
+ races.push(aborted.promise)
315
+ }
316
+ const deadline = Promise.withResolvers()
317
+ const deadlineTimer = setTimeout(() => {
318
+ startupOperation?.cancel()
319
+ deadline.reject(new Error('PTY shell did not reach readiness before startup timeout'))
320
+ }, timeoutMs)
321
+ races.push(deadline.promise)
322
+ try {
323
+ signal?.throwIfAborted()
324
+ await Promise.race([start(), ...races])
325
+ } finally {
326
+ clearTimeout(deadlineTimer)
327
+ if (signal !== undefined && onAbort !== undefined) signal.removeEventListener('abort', onAbort)
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Reject a failed startup only after its unpublished resources reach
333
+ * quiescence (copied from the official `@deepseek-ai/dsh-terminal-bash`,
334
+ * which does not export it): a cleanup failure is reported as
335
+ * `TerminalBackendCleanupError` so a leaked PTY is distinguishable from the
336
+ * startup failure that caused it.
337
+ * @param {unknown} error - the startup or session-factory failure.
338
+ * @param {() => unknown} cleanup - releases the resource the failure left behind.
339
+ * @returns {Promise<never>} never resolves; always rejects.
340
+ */
341
+ async function rejectAfterStartupCleanup(error, cleanup) {
342
+ try {
343
+ await cleanup()
344
+ } catch (cleanupError) {
345
+ throw new TerminalBackendCleanupError(error, cleanupError)
346
+ }
347
+ throw error
348
+ }
349
+
350
+ /**
351
+ * Local fish PTY backend registered under the configured type (`fish`):
352
+ * spawns `fish --no-config -i` (config-overridable) with the fish child
353
+ * environment, confined through the shared sandbox policy, and brings it to
354
+ * readiness with the fish prompt setup. Everything else (bounded output,
355
+ * readiness polling, terminal-protocol replies, cleanup) is inherited from
356
+ * `BashTerminalBackend`/`LocalPtySession`.
357
+ */
358
+ export class FishTerminalBackend extends BashTerminalBackend {
359
+ /**
360
+ * Spawn and bring up one fish PTY session for a spec the registry (or the
361
+ * self-managed persistent tool) resolved. Resolves with the ready session
362
+ * the configured session factory built (the base class declares the concrete
363
+ * `LocalPtySession` return, which this package cannot name).
364
+ * @param {import('@deepseek-ai/dsh-terminal').TerminalBackendSpawnSpec} spec - the identified spawn request.
365
+ */
366
+ async spawn(spec) {
367
+ spec.signal?.throwIfAborted()
368
+ // The inherited fields (`ctx`, `config`, `spawnTerminal`, `createSession`)
369
+ // are TS-private upstream, so they are read through the one structural
370
+ // view declared above instead of repeating the same suppression per use.
371
+ const backend = /** @type {BashBackendInternals} */ (/** @type {unknown} */ (this))
372
+ ensureSandboxModeFence(backend.ctx, spec.owner)
373
+ // Resolve services through `ctx.get` (never property access): the
374
+ // backend may be constructed with a plugin context whose inject list
375
+ // does not declare these services (the policy plugin's ctx), and Cordis
376
+ // property access would throw "cannot get property … without inject".
377
+ // `ctx.get` resolves any registered service regardless of inject.
378
+ const policy = backend.ctx.get('sandboxPolicy').resolve({ session: spec.owner.session })
379
+ const argv = await spawnArgv(backend.ctx, backend.config, policy, spec.signal)
380
+ // Confinement is async, so the caller may have cancelled while it ran.
381
+ spec.signal?.throwIfAborted()
382
+ if (argv[0] === undefined) throw new Error('terminal-fish: sandbox returned empty argv')
383
+ const terminal = await backend.spawnTerminal({
384
+ argv,
385
+ cwd: spec.cwd ?? policy.workspaceRoot,
386
+ env: childEnvironment(spec),
387
+ rows: backend.config.rows,
388
+ cols: backend.config.cols,
389
+ // The provider advertises this as the child's `TERM` (it overwrites
390
+ // whatever `env` carried with it), so it must be the same `dumb` the
391
+ // fish prompt readiness contract is verified against.
392
+ terminalType: 'dumb',
393
+ graceMs: backend.config.disposeGraceMs,
394
+ signal: spec.signal,
395
+ })
396
+ let session
397
+ try {
398
+ session = backend.createSession(terminal, backend.config)
399
+ } catch (error) {
400
+ // A failed session factory must not leak the freshly allocated terminal.
401
+ return rejectAfterStartupCleanup(error, () => terminal.terminate())
402
+ }
403
+ try {
404
+ await startupSession(session, backend.config.timeoutMs, spec.signal)
405
+ return session
406
+ } catch (error) {
407
+ return rejectAfterStartupCleanup(error, () => session.close('PTY startup failed'))
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Register the local fish PTY backend in a `terminals` registry. The bundle
414
+ * patch does NOT mount this plugin (host-plane profiles have no `terminals`
415
+ * service); this entry point is for direct assembly and tests — and for the
416
+ * real `minimal` preset composition, whose entry-local registry can pick the
417
+ * backend up explicitly. Plugin configuration mirrors the official
418
+ * `@deepseek-ai/dsh-terminal-bash` Config surface (sans dialect), with fish
419
+ * defaults: backend type `fish`, `fish --no-config -i` argv.
420
+ * @param {import('@deepseek-ai/cordis').Context} ctx - the harness context.
421
+ * @param {import('@deepseek-ai/dsh-terminal-bash').TerminalLocalConfig} [config] - optional backend configuration.
422
+ */
423
+ export function apply(ctx, config = {}) {
424
+ const resolved = resolveConfig(config)
425
+ validateConfig(resolved)
426
+ ctx.terminals.registerBackend(new FishTerminalBackend(ctx, resolved))
427
+ }