zettcode-server 0.1.1__tar.gz → 0.4.0__tar.gz

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.
@@ -24,7 +24,14 @@ docs/.vitepress/.temp/
24
24
  # The TypeScript client is built from its sources; only dist/ is published.
25
25
  clients/typescript/dist/
26
26
 
27
+ # The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
28
+ # the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
29
+ packages/zettcode-gateway/src/zettcode/gateway/static/
30
+
27
31
  # The palette gallery is generated by `make palettes`: every widget painted in
28
32
  # every palette, written by docs/scripts/palette_gallery.py. Regenerate it
29
33
  # rather than committing a 2 MB copy that goes stale the moment a colour moves.
30
34
  docs/internal/palettes.html
35
+
36
+ # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
+ packages/order.txt
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-server
3
+ Version: 0.4.0
4
+ Summary: The HTTP face of ZettCode: a FastAPI server that drives the SDK
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode
6
+ Author: Chang-LeHung
7
+ License-Expression: MIT
8
+ Keywords: api,server,zettcode
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Programming Language :: Python :: 3.15
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: fastapi<1,>=0.115
19
+ Requires-Dist: pydantic<3,>=2
20
+ Requires-Dist: starlette<2,>=0.48
21
+ Requires-Dist: uvicorn<1,>=0.35
22
+ Requires-Dist: zettcode-engine
23
+ Requires-Dist: zettcode-sdk
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "zettcode-server"
3
+ description = "The HTTP face of ZettCode: a FastAPI server that drives the SDK"
4
+ requires-python = ">=3.10"
5
+ dynamic = ["version"]
6
+ license = "MIT"
7
+ authors = [{name = "Chang-LeHung"}]
8
+ keywords = ["zettcode", "server", "api"]
9
+ classifiers = [
10
+ "Development Status :: 5 - Production/Stable",
11
+ "Programming Language :: Python :: 3.10",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Programming Language :: Python :: 3.14",
16
+ "Programming Language :: Python :: 3.15",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "zettcode-engine",
21
+ "zettcode-sdk",
22
+ "fastapi>=0.115,<1",
23
+ "starlette>=0.48,<2",
24
+ "uvicorn>=0.35,<1",
25
+ "pydantic>=2,<3",
26
+ ]
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/Chang-LeHung/zettcode"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.version]
36
+ # One version for the whole repository: the engine ships the file.
37
+ path = "../zettcode-engine/src/zettcode/version.py"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/zettcode"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["/src", "/pyproject.toml"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ asyncio_mode = "auto"
@@ -4,20 +4,20 @@ The server is a thin layer: an endpoint says what to ask the agent and how to le
4
4
  SDK does the work. It owns no model loop, no tools, and no session format, so a session begun here is one the
5
5
  terminal can open afterwards, and the other way round.
6
6
 
7
- The application is built by :func:`create_app`, and served by the ``zettcode-server`` program.
7
+ It is part of this package, not a package of its own: one ``pip install zettcode`` writes both console scripts, the
8
+ terminal's ``zettcode`` and the server's ``zettcode-server``. The application is built by :func:`create_app`, and
9
+ served by :mod:`zettcode.server.cli`.
8
10
  """
9
11
 
10
12
  from __future__ import annotations
11
13
 
12
14
  from typing import TYPE_CHECKING, Any
13
15
 
14
- __version__ = "0.1.1"
15
-
16
16
  if TYPE_CHECKING: # pragma: no cover - annotations only; importing the app costs FastAPI
17
17
  from .app import create_app
18
18
  from .settings import Settings
19
19
 
20
- __all__ = ["Settings", "__version__", "create_app"]
20
+ __all__ = ["Settings", "create_app"]
21
21
 
22
22
 
23
23
  def __getattr__(name: str) -> Any:
@@ -25,6 +25,7 @@ from zettcode.sdk.errors import (
25
25
  NothingToDoError,
26
26
  RunError,
27
27
  RunTimeoutError,
28
+ SendError,
28
29
  SessionBusyError,
29
30
  SessionLockedError,
30
31
  SessionNotFoundError,
@@ -33,8 +34,8 @@ from zettcode.sdk.errors import (
33
34
  from zettcode.sdk.models import add_model as add_model_entry
34
35
  from zettcode.sdk.models import remove_model as remove_model_entry
35
36
  from zettcode.sdk.models import update_model as update_model_entry
37
+ from zettcode.version import __version__
36
38
 
37
- from . import __version__
38
39
  from .runner import NoModelError, Runner, prepare, result_payload
39
40
  from .runner import Request as RunRequest
40
41
  from .schemas import (
@@ -42,6 +43,9 @@ from .schemas import (
42
43
  ApprovalAnswer,
43
44
  ApprovalPolicy,
44
45
  CancelledOut,
46
+ CommandIn,
47
+ CommandOut,
48
+ CommandsOut,
45
49
  CompactionOut,
46
50
  ContextOut,
47
51
  CurrentSessionOut,
@@ -50,11 +54,15 @@ from .schemas import (
50
54
  ErrorOut,
51
55
  HealthOut,
52
56
  HistoryOut,
57
+ InboxMessageOut,
58
+ InboxOut,
53
59
  MentionsOut,
54
60
  ModelCreate,
55
61
  ModelsOut,
56
62
  ModelUpdate,
63
+ PluginOut,
57
64
  PluginsOut,
65
+ PluginSwitchIn,
58
66
  PromptRequest,
59
67
  QuestionAnswer,
60
68
  RenameRequest,
@@ -64,6 +72,8 @@ from .schemas import (
64
72
  RunStateOut,
65
73
  SelfCompactionOut,
66
74
  SelfCompactionUpdate,
75
+ SendIn,
76
+ SentOut,
67
77
  SessionCreate,
68
78
  SessionOut,
69
79
  SessionsOut,
@@ -103,6 +113,7 @@ FAILURES: tuple[tuple[type[BaseException], int, str], ...] = (
103
113
  (NothingToDoError, 409, "nothing_to_do"),
104
114
  (NoModelError, 409, "no_model"),
105
115
  (ConfigurationError, 400, "invalid"),
116
+ (SendError, 404, "unreachable"),
106
117
  (RunTimeoutError, 504, "timeout"),
107
118
  (RunError, 502, "run"),
108
119
  (ZettCodeError, 500, "internal"),
@@ -157,6 +168,34 @@ def stream_of(runner: Runner, request: RunRequest, *, resume: bool = False) -> S
157
168
  )
158
169
 
159
170
 
171
+ def message_stream_of(runner: Runner, message_id: str) -> StreamingResponse:
172
+ """Answer one message another session sent, and stream the turn as ``/prompts`` streams one.
173
+
174
+ A message that is not waiting any more — answered already, or never arrived — is a 404, decided here, while a
175
+ status line is still possible; what fails once the stream is open becomes an ``error`` frame.
176
+ """
177
+ if not runner.ready:
178
+ raise NoModelError("No model is configured; add one with POST /models")
179
+ try:
180
+ frames = runner.stream_message(message_id)
181
+ except KeyError:
182
+ raise StarletteHTTPException(status_code=404, detail="No message with that id is waiting") from None
183
+
184
+ async def body() -> AsyncIterator[bytes]:
185
+ try:
186
+ async for name, payload in frames:
187
+ yield sse_frame(name, payload)
188
+ except Exception as error: # noqa: BLE001 - the status line is gone; the failure goes down the stream
189
+ _, code = failure_of(error)
190
+ yield sse_frame("error", error_body(code, str(error)))
191
+
192
+ return StreamingResponse(
193
+ body(),
194
+ media_type="text/event-stream",
195
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
196
+ )
197
+
198
+
160
199
  def runner_of(request: Request) -> Runner:
161
200
  """Return the runner the server's lifespan put on the application."""
162
201
  return request.app.state.runner
@@ -248,9 +287,14 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
248
287
  """
249
288
  if runner.busy:
250
289
  raise SessionBusyError("A run is in progress; change the models when it ends")
251
- added = await asyncio.to_thread(add_model_entry, body.entry(), path=settings.config_path)
252
- if not runner.ready:
253
- await runner.reload(model=added[-1].name)
290
+ entry = body.entry()
291
+ if runner.ready:
292
+ # The client the runs use changes with the file, so the model it lists and the one the next prompt can
293
+ # choose are the file's at once; a rebuild would drop the conversation in progress and its settings.
294
+ await asyncio.to_thread(runner.client.add_model, entry)
295
+ else:
296
+ added = await asyncio.to_thread(add_model_entry, entry, path=settings.config_path)
297
+ await runner.start(model=added[-1].name)
254
298
  return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
255
299
 
256
300
  @protected.patch("/models/{name}", response_model=ModelsOut, tags=["models"])
@@ -261,8 +305,10 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
261
305
  changes = body.changes()
262
306
  if not changes:
263
307
  raise ValueError("Name at least one field to change")
264
- await asyncio.to_thread(update_model_entry, name, changes, path=settings.config_path)
265
- await runner.reload(model=runner.current_model)
308
+ if runner.ready:
309
+ await asyncio.to_thread(runner.client.update_model, name, changes)
310
+ else:
311
+ await asyncio.to_thread(update_model_entry, name, changes, path=settings.config_path)
266
312
  return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
267
313
 
268
314
  @protected.delete("/models/{name}", response_model=ModelsOut, tags=["models"])
@@ -270,13 +316,15 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
270
316
  """Remove one model from the configuration file; the last one cannot go, since a client needs one."""
271
317
  if runner.busy:
272
318
  raise SessionBusyError("A run is in progress; change the models when it ends")
273
- await asyncio.to_thread(remove_model_entry, name, path=settings.config_path)
274
- await runner.reload()
319
+ if runner.ready:
320
+ await asyncio.to_thread(runner.client.remove_model, name)
321
+ else:
322
+ await asyncio.to_thread(remove_model_entry, name, path=settings.config_path)
275
323
  return ModelsOut(models=[model_out(model) for model in runner.models_now()], current=runner.current_model)
276
324
 
277
325
  @protected.get("/settings", response_model=SettingsOut, tags=["models"])
278
326
  async def settings_out(runner: RunnerDep) -> SettingsOut:
279
- """Return the model, effort and permission a run starts with."""
327
+ """Return the model, effort, permission and harness a run starts with."""
280
328
  client = runner.client
281
329
  return SettingsOut(
282
330
  model=model_out(client.model),
@@ -284,18 +332,20 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
284
332
  efforts=list(client.efforts),
285
333
  permission=client.permission,
286
334
  permissions=list(client.permissions),
335
+ harness=client.harness,
287
336
  )
288
337
 
289
338
  @protected.patch("/settings", response_model=SettingsOut, tags=["models"])
290
339
  async def change_settings(update: SettingsUpdate, runner: RunnerDep) -> SettingsOut:
291
- """Change the model, the reasoning effort, the permission level or the compaction point for the prompts that follow."""
340
+ """Change the model, effort, permission level, harness or compaction point for the prompts that follow."""
292
341
  if (
293
342
  update.model is None
294
343
  and update.effort is None
295
344
  and update.permission is None
345
+ and update.harness is None
296
346
  and update.compact_percent is None
297
347
  ):
298
- raise ValueError("Name at least one of model, effort, permission or compact_percent")
348
+ raise ValueError("Name at least one of model, effort, permission, harness or compact_percent")
299
349
  client = runner.client
300
350
  if update.model is not None:
301
351
  client.use_model(update.model)
@@ -303,6 +353,8 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
303
353
  client.use_effort(update.effort)
304
354
  if update.permission is not None:
305
355
  client.use_permission(update.permission)
356
+ if update.harness is not None:
357
+ client.use_harness(update.harness)
306
358
  unsaved: str | None = None
307
359
  if update.compact_percent is not None:
308
360
  if runner.busy:
@@ -313,16 +365,19 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
313
365
 
314
366
  @protected.get("/sessions", response_model=SessionsOut, tags=["sessions"])
315
367
  async def list_sessions(runner: RunnerDep, limit: Annotated[int, Query(ge=1, le=200)] = 20) -> SessionsOut:
316
- """List the stored sessions of this workspace, most recently active first."""
368
+ """List the stored sessions of this workspace, most recently active first, and which of them are running."""
317
369
  client = runner.client
318
370
  stored = await client.sessions.list(limit=limit)
319
- return SessionsOut(sessions=[session_out(item, client.session.id) for item in stored])
371
+ running = runner.running_sessions()
372
+ return SessionsOut(sessions=[session_out(item, client.session.id, item.id in running) for item in stored])
320
373
 
321
374
  @protected.post("/sessions", response_model=SessionOut, status_code=201, tags=["sessions"])
322
375
  async def new_session(runner: RunnerDep, body: SessionCreate | None = None) -> SessionOut:
323
- """Start an empty conversation and make it the one the next prompt continues."""
324
- client = runner.client
325
- session = client.sessions.new()
376
+ """Start an empty conversation and make it the one the next prompt continues.
377
+
378
+ A run in the conversation being left goes on there, and shows as running in the list.
379
+ """
380
+ session = await runner.new_session()
326
381
  if body is not None and body.title:
327
382
  await session.rename(body.title)
328
383
  return SessionOut(id=session.id, title=session.title, current=True)
@@ -334,8 +389,12 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
334
389
 
335
390
  @protected.post("/sessions/{session_id}/open", response_model=CurrentSessionOut, tags=["sessions"])
336
391
  async def open_session(session_id: str, runner: RunnerDep) -> CurrentSessionOut:
337
- """Continue a stored conversation: the next prompt goes to it."""
338
- session = await runner.client.sessions.open(session_id)
392
+ """Continue a stored conversation: the next prompt goes to it.
393
+
394
+ A run in the conversation being left goes on there. Opening a conversation that is running brings it back as
395
+ it is, run and all.
396
+ """
397
+ session = await runner.open_session(session_id)
339
398
  return current_session_out(session)
340
399
 
341
400
  @protected.post("/sessions/current/rename", response_model=SessionOut, tags=["sessions"])
@@ -421,6 +480,30 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
421
480
  """
422
481
  return stream_of(runner, request)
423
482
 
483
+ @protected.get("/commands", response_model=CommandsOut, tags=["prompts"])
484
+ async def commands(runner: RunnerDep) -> CommandsOut:
485
+ """List the commands every front end may run: a name, a summary, and what the run takes.
486
+
487
+ These are what the plugins registered on ``ctx.commands`` — text and a toast, or a preset for a turn — so a
488
+ client with no terminal can offer them without reimplementing any of them.
489
+ """
490
+ return CommandsOut(commands=list(runner.commands()))
491
+
492
+ @protected.post("/commands/{name}", response_model=CommandOut, tags=["prompts"])
493
+ async def run_command(name: str, body: CommandIn, runner: RunnerDep) -> CommandOut:
494
+ """Run one command by name and return what it answered with.
495
+
496
+ A command that answers with a prompt is not run as a turn here: the client sends ``POST /prompts`` with that
497
+ text and the ``shown`` line, so the turn belongs to the conversation the client is looking at, like every
498
+ other prompt.
499
+
500
+ ``404 not_found`` when no plugin registered that name.
501
+ """
502
+ try:
503
+ return await runner.run_command(name, body.argument)
504
+ except KeyError:
505
+ raise StarletteHTTPException(status_code=404, detail=f"No plugin registered the command {name!r}") from None
506
+
424
507
  @protected.post("/runs/resume", tags=["prompts"], responses={200: {"content": {"text/event-stream": {}}}})
425
508
  async def resume_run(request: ResumeRequest, runner: RunnerDep) -> StreamingResponse:
426
509
  """Continue the interrupted request where it stopped, streaming it like ``/prompts``.
@@ -456,7 +539,8 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
456
539
  @protected.post("/runs/steer", response_model=SteeredOut, tags=["prompts"])
457
540
  async def steer(body: SteerIn, runner: RunnerDep) -> SteeredOut:
458
541
  """Send a message into the run in progress; it drops what it was doing to take it into account."""
459
- return SteeredOut(steered=runner.steer(body.text))
542
+ taken = runner.steer(body.text, body)
543
+ return SteeredOut(steered=taken is not None, text=taken)
460
544
 
461
545
  @protected.post("/runs/cancel", response_model=CancelledOut, tags=["prompts"])
462
546
  async def cancel_run(runner: RunnerDep) -> CancelledOut:
@@ -465,7 +549,12 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
465
549
 
466
550
  @protected.post("/sessions/current/ask", response_model=AnswerOut, tags=["sessions"])
467
551
  async def ask_side_question(body: SideQuestionRequest, runner: RunnerDep) -> AnswerOut:
468
- """Ask something answered from the conversation but kept out of it, as ``/btw`` does."""
552
+ """Ask something answered from the conversation but kept out of it, as ``/btw`` does.
553
+
554
+ The client that asked is the reader, so the run is stored with the reader's origin and a reopen may restore it;
555
+ nothing has to be passed for that. Only the terminal restores one on resume — ``/history`` leaves every side
556
+ exchange out — and what is drawn live is the front end's own decision, as for the streamed sibling.
557
+ """
469
558
  if runner.busy:
470
559
  raise SessionBusyError("A run is already in progress; ask again when it ends")
471
560
  return AnswerOut(answer=await runner.client.session.ask(body.question, timeout=body.timeout))
@@ -522,11 +611,53 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
522
611
  headers={"Content-Disposition": f'attachment; filename="zettcode-{session.id[:8]}.html"'},
523
612
  )
524
613
 
614
+ @protected.post("/sessions/current/send", response_model=SentOut, tags=["sessions"])
615
+ async def send_message(body: SendIn, runner: RunnerDep) -> SentOut:
616
+ """Send a message to another ZettCode session explicitly, without a model turn."""
617
+ to, disposition = await runner.send(body.to, body.text, mode=body.mode)
618
+ return SentOut(to=to, disposition=disposition)
619
+
525
620
  @protected.get("/plugins", response_model=PluginsOut, tags=["models"])
526
621
  async def plugins(runner: RunnerDep) -> PluginsOut:
527
- """List the plugins this server loaded, and the ones that could not load."""
622
+ """List the plugins this server loaded, the ones that failed, and every one a switch can reach."""
528
623
  report = runner.client.plugins
529
- return PluginsOut(loaded=list(report.loaded), failures=list(report.failures))
624
+ return PluginsOut(
625
+ loaded=list(report.loaded),
626
+ failures=list(report.failures),
627
+ entries=[
628
+ PluginOut(
629
+ name=entry.name,
630
+ origin=entry.origin,
631
+ builtin=entry.builtin,
632
+ enabled=entry.enabled,
633
+ failure=entry.failure,
634
+ setting=entry.setting,
635
+ switchable=entry.switchable,
636
+ why=entry.why,
637
+ )
638
+ for entry in report.entries
639
+ ],
640
+ )
641
+
642
+ @protected.put("/plugins/{name}", response_model=PluginOut, tags=["models"])
643
+ async def switch_plugin(name: str, body: PluginSwitchIn, runner: RunnerDep) -> PluginOut:
644
+ """Turn one plugin's work on or off for the next start, and return it as the list now shows it.
645
+
646
+ Nothing is loaded or unloaded here: a switch is read once, when a process starts. A compiled-in plugin is
647
+ switched by a configuration key in the server's configuration file, an installed one by [plugins] disable there;
648
+ a client that wants the change now restarts the server, which is what the extension does.
649
+ """
650
+ entry = await asyncio.to_thread(runner.client.set_plugin_enabled, name, body.enabled)
651
+ return PluginOut(
652
+ name=entry.name,
653
+ origin=entry.origin,
654
+ builtin=entry.builtin,
655
+ enabled=entry.enabled,
656
+ failure=entry.failure,
657
+ setting=entry.setting,
658
+ switchable=entry.switchable,
659
+ why=entry.why,
660
+ )
530
661
 
531
662
  @protected.get("/mentions", response_model=MentionsOut, tags=["models"])
532
663
  async def mentions(
@@ -534,9 +665,31 @@ def create_app(settings: Settings, *, client_factory: ClientFactory | None = Non
534
665
  query: Annotated[str, Query(description="What was typed after the @, without it")] = "",
535
666
  limit: Annotated[int, Query(ge=1, le=200)] = 50,
536
667
  ) -> MentionsOut:
537
- """List the files, skills and plugin resources an ``@`` in the prompt can reference."""
668
+ """List the files, skills, sessions and plugin resources an ``@`` in the prompt can reference."""
538
669
  return MentionsOut(mentions=[mention_out(item) for item in await runner.mentions(query, limit)])
539
670
 
671
+ @protected.get("/messages", response_model=InboxOut, tags=["prompts"])
672
+ async def messages(runner: RunnerDep) -> InboxOut:
673
+ """List the messages other sessions sent that this chat has not answered yet, oldest first."""
674
+ return InboxOut(
675
+ messages=[
676
+ InboxMessageOut(
677
+ id=waiting.id,
678
+ sender_id=waiting.message.sender_id,
679
+ sender=waiting.message.sender,
680
+ text=waiting.message.text,
681
+ via=waiting.message.via,
682
+ hops=waiting.message.hops,
683
+ )
684
+ for waiting in runner.waiting()
685
+ ]
686
+ )
687
+
688
+ @protected.post("/messages/{message_id}", tags=["prompts"], responses={200: {"content": {"text/event-stream": {}}}})
689
+ async def answer_message(message_id: str, runner: RunnerDep) -> StreamingResponse:
690
+ """Answer one message another session sent, streaming the turn exactly as ``/prompts`` does."""
691
+ return message_stream_of(runner, message_id)
692
+
540
693
  @protected.post("/doctor", response_model=DoctorOut, tags=["models"])
541
694
  async def doctor(runner: RunnerDep, body: DoctorRequest | None = None) -> DoctorOut:
542
695
  """Ask every configured model one short question and report which of them answer, as ``zettcode doctor``."""
@@ -11,7 +11,8 @@ import os
11
11
  import sys
12
12
  from pathlib import Path
13
13
 
14
- from . import __version__
14
+ from zettcode.version import __version__
15
+
15
16
  from .settings import DEFAULT_HOST, DEFAULT_PERMISSION, DEFAULT_PORT, TOKEN_VARIABLE, Settings
16
17
 
17
18
 
@@ -41,9 +42,14 @@ def build_parser() -> argparse.ArgumentParser:
41
42
  )
42
43
  parser.add_argument(
43
44
  "--permission",
44
- choices=("deny", "ask", "safe", "auto"),
45
+ choices=("deny", "ask", "safe", "full", "auto"),
45
46
  default=DEFAULT_PERMISSION,
46
- help=f"shell permission a run starts at (default: {DEFAULT_PERMISSION})",
47
+ help=f"shell permission a run starts at (default: {DEFAULT_PERMISSION}); auto is the old name of full",
48
+ )
49
+ parser.add_argument(
50
+ "--harness",
51
+ action="store_true",
52
+ help="start with the just-in-time harness on: the model may write and run its own Python tools (default: off)",
47
53
  )
48
54
  parser.add_argument("--model", default=None, help="model to start on, by name (default: the first configured)")
49
55
  parser.add_argument("--effort", default=None, help="reasoning effort to start on (default: the model's own)")
@@ -59,10 +65,8 @@ def build_parser() -> argparse.ArgumentParser:
59
65
 
60
66
 
61
67
  def version_text() -> str:
62
- """Return the line ``--version`` prints: the server, and the agent it serves."""
63
- from zettcode import __version__ as agent
64
-
65
- return f"zettcode-server {__version__} (zettcode {agent})"
68
+ """Return the line ``--version`` prints: the server and the agent are one package, so one version."""
69
+ return f"zettcode-server {__version__}"
66
70
 
67
71
 
68
72
  def settings_from(argv: list[str] | None = None) -> Settings:
@@ -79,6 +83,7 @@ def settings_from(argv: list[str] | None = None) -> Settings:
79
83
  port=args.port,
80
84
  token=args.token or os.environ.get(TOKEN_VARIABLE) or None,
81
85
  permission=args.permission,
86
+ harness=args.harness,
82
87
  model=args.model,
83
88
  effort=args.effort,
84
89
  config_path=args.config.expanduser() if args.config is not None else None,
File without changes